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>
88 lines
3.8 KiB
JavaScript
88 lines
3.8 KiB
JavaScript
// 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.
|
|
|
|
// `query` is destructured from the core facade at require time, like every other
|
|
// *.db.js here. The facade resolves `ctx` per call, so taking it now is safe even
|
|
// though `ctx` does not exist yet when this file is first required.
|
|
const { query } = require('../../core')
|
|
|
|
/** ServUO's `RankDefinition.Ranks[4]` is Leader, and 4 is the top of the ladder. */
|
|
const LEADER_RANK = 4
|
|
|
|
/**
|
|
* 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, m.`rank`, m.rank_cliloc, m.rank_name, " +
|
|
` 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],
|
|
)
|
|
|
|
/**
|
|
* Every member at leader rank — rank 4, the top of ServUO's `RankDefinition.Ranks`.
|
|
*
|
|
* A set, not a single row, and that is the whole reason Protocol 4 grew a per-member
|
|
* rank: the guild board carries one `leader_serial`, so before this the website could
|
|
* only ever be told about one leader, while a UO guild routinely has several.
|
|
*
|
|
* A NULL rank is excluded by the comparison, which is correct — the shard withholds
|
|
* the rank for a staff account rather than publishing the Leader its getter falsely
|
|
* reports, and "not known" must not be read as "leads this guild".
|
|
*/
|
|
const listGuildLeaders = (guildId) =>
|
|
query(
|
|
'SELECT serial FROM shard_guild_members WHERE guild_id = ? AND `rank` >= ? ORDER BY name ASC',
|
|
[guildId, LEADER_RANK],
|
|
)
|
|
|
|
module.exports = { listGuilds, findGuild, listGuildMembers, listGuildLeaders, LEADER_RANK }
|