From 0622ed00e0177cd50e91b6b02cdfee1932efe51d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 17:44:04 -0500 Subject: [PATCH] docs(link): roster members carry guild rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends Protocol 4 in place rather than bumping it: the protocol has not reached `main`, and a bump is owed only once a protocol has been released. The roster shipped as the standard actor object, which carries no rank. Teams phase 2 found the consequence -- the website could learn leadership only from the board's single `leader` field, so it could name exactly one leader while a UO guild routinely has several, and TEAMS.md §2.5 treats multiple leaders as the normal case. Roster members now carry `rank` (0-4, 4 being Leader) plus `rankCliloc`, or `rankName` where a shard's custom rank definitions use literal names. Rank is on roster members only -- it is a property of a mobile's membership of THIS guild, not of the mobile, and every other actor the bridge writes is a bystander, a killer or a governor. Both files carry the trap this found, because it is the kind of thing a consumer gets wrong silently: **`PlayerMobile.GuildRank` returns Leader for anyone at GameMaster or above, whatever their real rank.** It is a gameplay convenience so staff can operate a guild stone, and the true value has no accessor -- so the bridge omits the rank entirely for a staff account rather than publish a leadership claim it knows is false. An absent rank therefore means "not known", and a consumer must read it as neither 0 (which silently demotes them) nor leadership (which republishes the lie). INTEGRATION.md also states the other half plainly for an outside integrator: `guild.update`'s single `leader` is the founder-leader, not the set of leaders, so "who leads this guild" is a read of the roster's ranks. Recorded too: the sidecar needs no change and no store migration, because it treats roster members as opaque values and never reads a field inside one. That is the forwarder design paying off, and it is worth having written down the next time someone adds a member field. Pairs with servuo-plugins (the emitter) and Module-uo (the ingest and the provider). Co-Authored-By: Claude --- link/INTEGRATION.md | 9 ++++++++- link/v4.md | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 05cc847..25f00d5 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -286,11 +286,18 @@ Guilds expose only one in-game event (a member joining), so the roster is polled | `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's leader/alliance/name changed, its member count moved, or its first sight this connection. | | `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. | | `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). | -| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** | +| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects **carrying rank**) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** | | `guild.leave` **(4)** | `id`, `name`, `who` (serial string) | Real-time: a member left. Advisory — see below. | The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. Note `guild.leave`'s `who` is a bare **serial string**, not an actor object: the mobile has already left, so there is nothing to attribute. +**A roster member carries rank as well.** `rank` is 0–4 with 4 being Leader, plus `rankCliloc` (the cliloc the game names that rank with) or `rankName` when a shard uses custom rank definitions with literal names. Only the raw rank is sent: ServUO ships no text for those clilocs, so turning 1062960 into "Warlord" is the consumer's job. + +Two things to get right, both of which bite: + +- **Several members can hold rank 4.** `guild.update`'s single `leader` is the guild's founder-leader; it is not the set of leaders. If you need "who leads this guild", read the roster's ranks and treat `leader` as one more entry rather than the answer. +- **An absent `rank` means "not known" — never 0, and never leadership.** It has one deliberate cause: `PlayerMobile.GuildRank` reports Leader for any account at GameMaster or above whatever their real rank, so the bridge omits the rank for staff rather than publishing a claim it knows is false. Defaulting a missing rank to 0 silently demotes them; reading absence as leadership republishes exactly the lie the bridge avoided. + **On Protocol 4.** Before it, a guild's membership was a *count* and a leave surfaced only as that count dropping. `guild.roster` carries the members themselves, and `guild.leave` names who went. `guild.leave` is **advisory**: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep. diff --git a/link/v4.md b/link/v4.md index 726f73b..9789d9e 100644 --- a/link/v4.md +++ b/link/v4.md @@ -102,10 +102,11 @@ catch-up takes seconds instead of one sweep interval per batch. ### 2.3 What a roster member carries Each entry is the standard actor object — `serial`, `name`, `player`, plus `acct` when the mobile has -an account and `webId` when that account is linked: +an account and `webId` when that account is linked — **and the member's rank in this guild**: ```jsonc -{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true} +{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true, + "rank":4,"rankCliloc":1062959} ``` `acct` is **genuinely optional**: a `PlayerMobile` can have no `Account` at all, and the local test @@ -114,6 +115,42 @@ world contains such mobiles. Consumers must not assume it is present. These identity fields are emitted unconditionally, by design — the sidecar is a forwarder, and deciding who may see them is the website's job. See §4. +#### Rank + +> **Added 2026-08-17, amending Protocol 4 in place.** The version is **not** bumped: Protocol 4 has +> not reached `main`, and a protocol owes a bump only once it has been released. The roster shipped +> without rank, and Teams phase 2 then found the consequence — the website could learn leadership +> only from the board's single `leader` field, so it could name exactly one leader while a UO guild +> routinely has several. + +| Field | Shape | When | +| --- | --- | --- | +| `rank` | integer 0–4, 4 being Leader (`RankDefinition.Ranks`) | whenever the rank is known | +| `rankCliloc` | integer — the cliloc the game names the rank with | when the rank's name is a cliloc, i.e. the five stock ranks | +| `rankName` | string | when a shard's custom rank definition carries a literal name instead | + +**Rank is on roster members only.** It is a property of a mobile's membership of *this* guild, not of +the mobile, and every other actor the bridge writes is a bystander, a killer or a governor, where +guild rank is meaningless. + +**Only the raw rank is emitted, never a resolved label.** ServUO names the five stock ranks with +clilocs (1062959–1062963) and ships no text for them, so the shard cannot produce "Warlord" without a +client-file table it does not have. The website module does have one, and resolving a game term is +its job in any case. + +**An absent rank means "not known", and a consumer must not read it as 0 or as leadership.** It has +one deliberate cause, which is the trap this amendment found: `PlayerMobile.GuildRank` returns +`RankDefinition.Leader` for anyone at **GameMaster or above**, whatever their real rank. That is a +gameplay convenience so staff can operate a guild stone, not a claim about who leads the guild — and +the true value is in a private field with no accessor. So the bridge writes **no rank at all** for a +staff account rather than publishing a leadership claim it knows to be false. A staff member who +genuinely leads their guild therefore appears unranked, which is a visible gap rather than a lie on a +public roster. + +**The sidecar is unaffected.** It treats roster members as opaque values and never reads a field +inside one, so a new member field needs no sidecar change and no store migration — which is the +forwarder design (§3) doing its job. + --- ## 3. The sidecar side