feat(teams): ingest guild rank, and report every leader rather than one
Some checks failed
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Failing after 34s

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>
This commit is contained in:
2026-08-17 17:42:32 -05:00
parent c6929c6bae
commit 99d1ca25a7
6 changed files with 273 additions and 41 deletions

View File

@@ -241,11 +241,29 @@ CREATE TABLE IF NOT EXISTS shard_guild_members (
acct VARCHAR(120) NULL, -- absent for a mobile with no account
web_id INT NULL, -- set only when the account is linked
is_player TINYINT(1) NOT NULL DEFAULT 1,
-- Guild rank, 0-4, with 4 being Leader (ServUO RankDefinition.Ranks). NULL means
-- "not known", which is a real state and not a demotion: the shard omits the rank
-- for a staff account, because PlayerMobile.GuildRank reports Leader for anyone at
-- GameMaster or above whatever their actual rank, and publishing that would put a
-- staff member on a public roster as a guild leader.
-- Backticked, like `int` on shard_online: RANK is a reserved word in MySQL 8 and
-- a non-reserved keyword in MariaDB, so it parses here bare but must not be
-- written that way anywhere it might not.
`rank` TINYINT NULL,
-- The rank's NAME, as the game states it: a cliloc id for the five standard ranks
-- (1062959-1062963, which ship with no text), or a literal string when a shard has
-- replaced the rank table with custom definitions. Resolving one to a label is this
-- module's job -- it owns the cliloc table and the game vocabulary.
rank_cliloc INT NULL,
rank_name VARCHAR(64) NULL,
t BIGINT NULL, -- roster event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (guild_id, serial),
INDEX idx_shard_guild_members_acct (acct),
INDEX idx_shard_guild_members_web (web_id)
INDEX idx_shard_guild_members_web (web_id),
-- Leadership is "rank >= 4", asked per guild, which is the query the Team provider
-- runs on every reconcile.
INDEX idx_shard_guild_members_rank (guild_id, rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
@@ -669,4 +687,14 @@ INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated'
-- only a database that has never seen the key gets the default. Nothing in core
-- reads either one; `game_account_signup` is read through ctx.settings by
-- server/utils/gameSignup.js, which owns the policy.
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
-- Protocol 4 guild rank, added to databases that already have shard_guild_members.
--
-- The table itself is new in Protocol 4 and unreleased, so no production install has
-- it — but `edge` deployments do, from the roster work that landed before the rank
-- amendment, and CREATE TABLE IF NOT EXISTS adds a table and never a column. This is
-- the same gap the sidecar's own store hit when `guilds.members` was added.
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);