feat(bridge): carry guild rank on roster members #13

Merged
whitlocktech merged 1 commits from feat/protocol4-guild-rank into edge 2026-08-17 22:49:12 +00:00
Member

Amends Protocol 4 in place — no bump. The protocol is on edge and unreleased, and a bump is owed only once one has reached main. PROTOCOL_VERSION and overlay.toml both stay at 4.

Spec: docs #155 · pairs with Module-uo #10

Why

Phase 1 shipped the roster member as the standard actor object, which carries no guild rank. Teams phase 2 found the consequence: the website could learn leadership only from the board's single leader field, so getTeamLeaders() could name exactly one member — while a UO guild routinely has several at rank 4, and TEAMS.md §2.5 treats multiple leaders as the normal case.

What

Roster members carry rank (0–4, 4 being Leader per RankDefinition.Ranks) plus rankCliloc, or rankName when a custom rank definition uses a literal string.

Only the raw rank goes on the wire. ServUO names the five stock ranks with clilocs and ships no text for them, so this 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 anyway.

withGuildRank is a parameter on Actors() rather than a change to the shared actor writer — rank is a property of a mobile's membership of this guild, not of the mobile, and every other actor this bridge writes is a bystander, a killer or a governor. WriteActor is split into a fields-only writer so both forms share one definition.

The trap this found — worth the review time

PlayerMobile.GuildRank returns RankDefinition.Leader for anyone at GameMaster or above, whatever their actual rank. It is a gameplay convenience so staff can operate a guild stone, and emphatically not a claim about who leads the guild — but it is what the only public accessor returns, and the true value sits in a private field with no getter.

Emitting it verbatim would have published every staff member in a guild as a guild leader on a public website. Staff are therefore written with no rank fields at all. A staff account that genuinely leads its guild shows as an unranked member — a visible gap rather than a false claim, which is the right way round given that roster reaches a public page.

Verification

No CI build here, so compiling is not evidence. Run against the local ServUO tree with a throwaway probe that synthesised a guild from real PlayerMobiles across the rank ladder, with one account promoted to GameMaster. The probe printed stored vs reported rank per member, so the getter's substitution is recorded rather than inferred:

[rankprobe] tester   storedRank=4 reportedRank=4 access=Player
[rankprobe] Seed000A storedRank=4 reportedRank=4 access=Player
[rankprobe] Seed001B storedRank=0 reportedRank=4 access=GameMaster   <-- the lie

The frame that came out the other end, captured off the socket:

{"serial":"0x2E0","name":"tester",  ...,"rank":4,"rankCliloc":1062959}
{"serial":"0x1F5","name":"Seed000A",...,"rank":4,"rankCliloc":1062959}   // two at once
{"serial":"0x1F6","name":"Seed000B",...,"rank":3,"rankCliloc":1062960}
{"serial":"0x1F7","name":"Seed000C",...,"rank":2,"rankCliloc":1062961}
{"serial":"0x1F8","name":"Seed001A",...,"rank":1,"rankCliloc":1062962}
{"serial":"0x1F9","name":"Seed001B",...}                                 // no rank: staff

Line parsed as valid JSON. dotnet build Scripts.csproj clean, 0 warnings. Probe deleted, tree rebuilt, deploy.ps1 -Verify reports 0 changes against the overlay. Shard killed without a world save, so the synthetic guild did not persist (Guilds.bin still 0 bytes).

The sidecar needs no change and no store migration — it treats roster members as opaque values and never reads a field inside one. That is the forwarder design doing its job, and it is now written down in the spec for the next person who adds a member field.


AI-assisted: written with Claude Code. Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.

Amends **Protocol 4 in place — no bump.** The protocol is on `edge` and unreleased, and a bump is owed only once one has reached `main`. `PROTOCOL_VERSION` and `overlay.toml` both stay at 4. Spec: docs #155 · pairs with Module-uo #10 ## Why Phase 1 shipped the roster member as the standard actor object, which carries no guild rank. Teams phase 2 found the consequence: the website could learn leadership only from the board's single `leader` field, so `getTeamLeaders()` could name **exactly one** member — while a UO guild routinely has several at rank 4, and `TEAMS.md` §2.5 treats multiple leaders as the normal case. ## What Roster members carry `rank` (0–4, 4 being Leader per `RankDefinition.Ranks`) plus `rankCliloc`, or `rankName` when a custom rank definition uses a literal string. **Only the raw rank goes on the wire.** ServUO names the five stock ranks with clilocs and ships no text for them, so this 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 anyway. `withGuildRank` is a parameter on `Actors()` rather than a change to the shared actor writer — rank is a property of a mobile's membership of *this* guild, not of the mobile, and every other actor this bridge writes is a bystander, a killer or a governor. `WriteActor` is split into a fields-only writer so both forms share one definition. ## The trap this found — worth the review time **`PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or above, whatever their actual rank.** It is a gameplay convenience so staff can operate a guild stone, and emphatically not a claim about who leads the guild — but it is what the only public accessor returns, and the true value sits in a private field with no getter. Emitting it verbatim would have published **every staff member in a guild as a guild leader on a public website.** Staff are therefore written with no rank fields at all. A staff account that genuinely leads its guild shows as an unranked member — a visible gap rather than a false claim, which is the right way round given that roster reaches a public page. ## Verification No CI build here, so compiling is not evidence. Run against the local ServUO tree with a throwaway probe that synthesised a guild from real `PlayerMobile`s across the rank ladder, with one account promoted to GameMaster. The probe printed stored vs reported rank per member, so the getter's substitution is **recorded rather than inferred**: ``` [rankprobe] tester storedRank=4 reportedRank=4 access=Player [rankprobe] Seed000A storedRank=4 reportedRank=4 access=Player [rankprobe] Seed001B storedRank=0 reportedRank=4 access=GameMaster <-- the lie ``` The frame that came out the other end, captured off the socket: ```jsonc {"serial":"0x2E0","name":"tester", ...,"rank":4,"rankCliloc":1062959} {"serial":"0x1F5","name":"Seed000A",...,"rank":4,"rankCliloc":1062959} // two at once {"serial":"0x1F6","name":"Seed000B",...,"rank":3,"rankCliloc":1062960} {"serial":"0x1F7","name":"Seed000C",...,"rank":2,"rankCliloc":1062961} {"serial":"0x1F8","name":"Seed001A",...,"rank":1,"rankCliloc":1062962} {"serial":"0x1F9","name":"Seed001B",...} // no rank: staff ``` Line parsed as valid JSON. `dotnet build Scripts.csproj` clean, 0 warnings. Probe deleted, tree rebuilt, `deploy.ps1 -Verify` reports **0 changes** against the overlay. Shard killed without a world save, so the synthetic guild did not persist (`Guilds.bin` still 0 bytes). **The sidecar needs no change and no store migration** — it treats roster members as opaque values and never reads a field inside one. That is the forwarder design doing its job, and it is now written down in the spec for the next person who adds a member field. --- AI-assisted: written with Claude Code. Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`.
wtclaude added 1 commit 2026-08-17 22:44:34 +00:00
Protocol 4 is still on `edge` and unreleased, so this amends it in place rather
than bumping: `PROTOCOL_VERSION` and `overlay.toml` both stay at 4. A bump is
only owed once a protocol has reached `main`.

Phase 1 shipped the roster member as the standard actor object, which carries no
guild rank. The consequence surfaced in Teams phase 2: the website could only
learn leadership from the board's single `leader` field, so `getTeamLeaders()`
could return exactly one member — while a UO guild routinely has several at rank
4, and TEAMS.md §2.5 treats multiple leaders as the normal case.

Roster members now carry `rank` (0-4, 4 being Leader per RankDefinition.Ranks)
plus `rankCliloc`, or `rankName` when a custom rank definition uses a literal
string instead of a cliloc. Only the raw rank goes on the wire: ServUO names the
five standard ranks with cliloc ids and ships no text for them, so this shard
cannot produce "Warlord" without a client-file table it does not have. The
website module has one, and resolving a game term is its job in any case.

`withGuildRank` is a parameter on `Actors()` rather than a change to the shared
actor writer. Rank is a property of a mobile's membership of THIS guild, not of
the mobile, and every other actor this bridge writes is a bystander, a killer or
a governor, where guild rank is meaningless. `WriteActor` is split into a
fields-only writer so both forms share one definition of an actor.

## The trap this found

**`PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at
GameMaster or above, whatever their actual rank.** It is a gameplay convenience
so staff can operate a guild stone, and it is emphatically not a claim about who
leads the guild -- but it is what the only public accessor returns, and the true
value sits in a private field. Emitting it verbatim would have published every
staff member in a guild as a guild leader on a public website.

Staff are therefore written with no rank fields at all. A staff account that
genuinely leads its guild shows as an unranked member, which is a visible gap
rather than a false claim -- the right way round, given the name on that roster
reaches a public page.

## Verification

This repo has no CI build, so compiling is not evidence. Run against the local
ServUO tree with a throwaway probe that synthesised a guild from real
PlayerMobiles across the rank ladder, with one account promoted to GameMaster.
The emitted frame:

  tester    rank 4  cliloc 1062959   (Leader)
  Seed000A  rank 4  cliloc 1062959   (Leader)  <- two at once, the point of this
  Seed000B  rank 3  cliloc 1062960   (Warlord)
  Seed000C  rank 2  cliloc 1062961   (Emissary)
  Seed001A  rank 1  cliloc 1062962   (Member)
  Seed001B  no rank fields                     <- GameMaster, stored rank 0,
                                                  getter reported rank 4

The probe printed stored vs reported rank per member, so the getter's substitution
is recorded rather than inferred: `Seed001B storedRank=0 reportedRank=4
access=GameMaster`. The line parsed as valid JSON.

`dotnet build Scripts.csproj` clean, 0 warnings. Probe deleted, tree rebuilt, and
`deploy.ps1 -Verify` reports 0 changes against the overlay. The shard was killed
without a world save, so the synthetic guild did not persist (Guilds.bin still 0
bytes).

**The sidecar needs no change.** It treats roster members as opaque values and
never reads a field inside one -- `accumulate_roster` moves them and
`upsert_guild_roster` stores them, both by value. That is the forwarder design
paying off.

Refs docs/link/v4.md §2.3

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit d57d9aad84 into edge 2026-08-17 22:49:12 +00:00
whitlocktech deleted branch feat/protocol4-guild-rank 2026-08-17 22:49:13 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#13
No description provided.