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>
255 lines
10 KiB
JavaScript
255 lines
10 KiB
JavaScript
// ── module-uo's Team provider ──────────────────────────────────────────────
|
|
//
|
|
// The three questions core asks this module about Teams
|
|
// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3).
|
|
// A UO guild is a Team; this file is the whole of the translation.
|
|
//
|
|
// **Every method returns an envelope, and answering `{ ok: false }` is a normal
|
|
// outcome, not a failure to handle.** Core's contract is that module
|
|
// unavailability becomes staleness and never emptiness, and the only way this
|
|
// module can say "I cannot answer" is to say so — an empty array would be read as
|
|
// an authoritative "there are none", which during a cold start is how every
|
|
// roster on the site gets emptied. So the guard below is the most important code
|
|
// in the file, and it is deliberately conservative: **an unreachable or
|
|
// never-connected sidecar refuses, rather than reporting the board it happens to
|
|
// still hold.**
|
|
//
|
|
// The board IS durable and would survive a sidecar outage, which is exactly what
|
|
// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell
|
|
// a board that is five minutes stale from one that is five days stale, and it
|
|
// makes destructive decisions — archiving Teams, departing members — from a
|
|
// complete answer. Reporting a stale board as authoritative would license those.
|
|
|
|
const core = require('../../core')
|
|
const db = require('./teamProvider.db')
|
|
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
|
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
|
const clilocs = require('../shardClilocs/shardClilocs.model')
|
|
|
|
const log = core.logger('teams')
|
|
|
|
/**
|
|
* ServUO's five stock rank names, by the cliloc id the game names them with.
|
|
*
|
|
* A fallback, not the source of truth: the operator's own cliloc table is consulted
|
|
* first, and a shard with custom rank definitions sends a literal string that beats
|
|
* both. This exists because the cliloc table is populated only if someone ran the
|
|
* client-file extraction, and a roster on a shard that has not should still say
|
|
* "Warlord" rather than nothing.
|
|
*/
|
|
const STANDARD_RANK_NAMES = {
|
|
1062959: 'Leader',
|
|
1062960: 'Warlord',
|
|
1062961: 'Emissary',
|
|
1062962: 'Member',
|
|
1062963: 'Ronin',
|
|
}
|
|
|
|
/** A refusal, in the shape core reads (§2.3). */
|
|
const refuse = (reason) => ({ ok: false, reason })
|
|
|
|
/**
|
|
* Is the bridge in a state where the board can be trusted as current?
|
|
*
|
|
* The board is only as good as the socket that fills it. Three states refuse, and
|
|
* they are asked in this order because each is a different thing being wrong:
|
|
*
|
|
* - **no uo-link configured** — there is no shard behind this website at all;
|
|
* - **the integration is disabled** — an admin turned it off, and the board is
|
|
* frozen at whatever it held;
|
|
* - **the socket is not connected** — the board is a snapshot of unknown age.
|
|
*
|
|
* The in-process socket state is preferred over the persisted status column,
|
|
* which is written on transitions: a process that has just started has not
|
|
* transitioned yet, so the column can still say `connected` from the last run
|
|
* while this process has never opened a socket.
|
|
*/
|
|
async function boardIsCurrent() {
|
|
const config = await uoLinkConfig.getSafe()
|
|
if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' }
|
|
if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' }
|
|
|
|
const state = uoLinkSocket.getState()
|
|
if (!state || !state.connected) {
|
|
return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
/**
|
|
* `getTeams()` — every guild on the board.
|
|
*
|
|
* `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a
|
|
* guild in-game keeps the id, so core sees "an id whose name changed" and applies
|
|
* its rename rule (archive plus create). That mapping is this module's to make —
|
|
* only the game knows what identity survives what (§10.5).
|
|
*
|
|
* `meta` carries the alliance, opaquely. Core stores and displays it and never
|
|
* branches on it, which is what lets a UO concept reach a Team page without core
|
|
* acquiring an opinion about alliances.
|
|
*/
|
|
async function getTeams() {
|
|
const ready = await boardIsCurrent()
|
|
if (!ready.ok) return refuse(ready.reason)
|
|
|
|
try {
|
|
const rows = await db.listGuilds()
|
|
return {
|
|
ok: true,
|
|
complete: true,
|
|
teams: rows.map((row) => ({
|
|
externalId: String(row.id),
|
|
name: row.name,
|
|
abbr: row.abbr || null,
|
|
meta: row.alliance ? { alliance: row.alliance } : null,
|
|
})),
|
|
}
|
|
} catch (err) {
|
|
log.warn('getTeams failed', { message: err.message })
|
|
return refuse(`guild board unreadable: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `getTeamMembers(externalId)` — one guild's roster.
|
|
*
|
|
* **A guild with no roster rows is refused, not reported empty**, unless the board
|
|
* itself says the guild has no members. Protocol 4's roster arrives on its own
|
|
* frames, separately from the `guild.update` that creates the board row, so there
|
|
* is a real window — a fresh guild, or a website that connected between the two —
|
|
* where core would otherwise be told authoritatively that a 155-member guild has
|
|
* nobody in it. The board's own `members` count is what distinguishes the two,
|
|
* and it is the only thing that can.
|
|
*/
|
|
async function getTeamMembers(externalId) {
|
|
const ready = await boardIsCurrent()
|
|
if (!ready.ok) return refuse(ready.reason)
|
|
|
|
try {
|
|
const [guild] = await db.findGuild(externalId)
|
|
if (!guild) return refuse(`guild ${externalId} is not on the board`)
|
|
|
|
const rows = await db.listGuildMembers(externalId)
|
|
if (!rows.length && guild.members > 0) {
|
|
return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
|
|
}
|
|
|
|
const labels = await rankLabels(rows)
|
|
return {
|
|
ok: true,
|
|
complete: true,
|
|
members: rows.map((row) => ({
|
|
memberKey: row.serial,
|
|
displayName: row.name || null,
|
|
rankLabel: labels.get(row.serial) || null,
|
|
// Rank 4 is Leader, and several members can hold it. A NULL rank is not a
|
|
// leader: the shard withholds the rank for a staff account rather than
|
|
// publishing the Leader its getter falsely reports, and "not known" must
|
|
// never be read as "leads this guild".
|
|
leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK,
|
|
online: Boolean(row.is_online),
|
|
userId: resolveUserId(row),
|
|
})),
|
|
}
|
|
} catch (err) {
|
|
log.warn('getTeamMembers failed', { externalId, message: err.message })
|
|
return refuse(`roster unreadable: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `getTeamLeaders(externalId)` — everyone at leader rank.
|
|
*
|
|
* **All of them, which is why Protocol 4 grew a per-member rank.** The guild board
|
|
* carries one `leader_serial`, so before the rank amendment this could only ever
|
|
* name a single member, while a UO guild routinely has several at rank 4 and
|
|
* TEAMS.md §2.5 treats multiple leaders as the normal case.
|
|
*
|
|
* The board's own `leader_serial` is folded in as a floor. It is the guild's
|
|
* founder-leader and it comes from a different frame (`guild.update`), so on a
|
|
* shard whose roster has not been re-emitted since the amendment it is the only
|
|
* leadership signal there is — and it should never be *lost* by moving to ranks.
|
|
*/
|
|
async function getTeamLeaders(externalId) {
|
|
const ready = await boardIsCurrent()
|
|
if (!ready.ok) return refuse(ready.reason)
|
|
|
|
try {
|
|
const [guild] = await db.findGuild(externalId)
|
|
if (!guild) return refuse(`guild ${externalId} is not on the board`)
|
|
|
|
const rows = await db.listGuildLeaders(externalId)
|
|
const leaders = rows.map((r) => r.serial)
|
|
|
|
if (guild.leader_serial && !leaders.includes(guild.leader_serial)) {
|
|
leaders.push(guild.leader_serial)
|
|
}
|
|
return { ok: true, leaders }
|
|
} catch (err) {
|
|
log.warn('getTeamLeaders failed', { externalId, message: err.message })
|
|
return refuse(`leadership unreadable: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve each member's rank to a display label, keyed by serial.
|
|
*
|
|
* The shard sends the rank's NAME as the game states it — a cliloc id for the five
|
|
* standard ranks, or a literal string for a custom rank definition — and never a
|
|
* resolved label, because ServUO ships no text for those clilocs. This module does
|
|
* have a cliloc table, which is why the resolution belongs here.
|
|
*
|
|
* Three sources, in order: a custom string wins, then the operator's cliloc table,
|
|
* then the five standard names. The last exists because the cliloc table is
|
|
* populated only if someone ran the client extraction, and a shard that has not
|
|
* should still read "Warlord" rather than nothing.
|
|
*
|
|
* Never throws: a rank label is decoration on a roster, and a lookup failure must
|
|
* not turn a good roster into a refusal.
|
|
*/
|
|
async function rankLabels(rows) {
|
|
const out = new Map()
|
|
const wanted = []
|
|
|
|
for (const row of rows) {
|
|
if (row.rank_name) {
|
|
out.set(row.serial, row.rank_name)
|
|
} else if (Number.isInteger(row.rank_cliloc)) {
|
|
wanted.push(row.rank_cliloc)
|
|
}
|
|
}
|
|
|
|
let resolved = new Map()
|
|
if (wanted.length) {
|
|
try {
|
|
resolved = await clilocs.resolveMany(wanted)
|
|
} catch (err) {
|
|
log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message })
|
|
}
|
|
}
|
|
|
|
for (const row of rows) {
|
|
if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue
|
|
const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null
|
|
if (label) out.set(row.serial, label)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* The site account behind a character, or null.
|
|
*
|
|
* `web_id` is what the shard itself asserted when it emitted the roster; the
|
|
* account-link join is the fallback for a member whose roster row predates their
|
|
* link. Both are coerced through the same check, because `web_id` arrives from
|
|
* the wire as a string.
|
|
*/
|
|
function resolveUserId(row) {
|
|
const fromRoster = Number.parseInt(row.web_id, 10)
|
|
if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster
|
|
const fromLink = Number.parseInt(row.linked_user_id, 10)
|
|
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
|
|
}
|
|
|
|
module.exports = { getTeams, getTeamMembers, getTeamLeaders, boardIsCurrent }
|