feat(guilds): a UO guild is a Team (Teams cutover 5/6) #16
Reference in New Issue
Block a user
No description provided.
Delete Branch "edge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Cutover 5 of 6. The module half of the Teams bet, against the core that landed in
RunicGateway/website#161.What lands
shard_guilds/shard_guild_members, plus per-member rank.registerTeamProvider— this module becomes the authoritative source of Teams for the deployment. The three required methods over the guild board, plusprojectRoster(the audience rungs are this module's; core has no such model) andpageUrlTemplate, because core cannot link to a page it does not own. An unreachable or never-connected sidecar refuses rather than reporting the board it still holds — core cannot tell a five-minute-old snapshot from a five-day-old one and makes destructive decisions from a complete answer.#15each declaration also names which contribution it wants, so core names no slot./guild— the module's own slash command. Core registers none: "guild" is this module's word, and core does not own it on a page or in a channel.The pin
ci/core-ref.jsonmoves from a phase 3edgesha to the cutover commit (963d734). It had been proving this module against a core older than half of it, andedgeis about to stop existing.routes.manifest.jsondoes not move — the frozen-manifest job compares core-with-module against core-without-module, so core's own new Teams routes cancel out and this module's 73 are unchanged. Verified locally against the cutover core before moving the pin.Verification
437 server tests · 42 client tests · frozen manifest clean against the new pin. The guild page was walked in a browser on a real core during phase 11, after the slot mechanism changed, and renders exactly as before.
AI disclosure
Written with Claude Code (Opus 5). Commits carry the
Co-Authored-Bytrailer.A UO guild is a Team. This registers module-uo as the authoritative source of them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three questions core asks, from the board and the roster Protocol 4 put there. `externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so core sees "an id whose name changed" and applies its rename rule rather than an unrelated new guild appearing beside the old one. That mapping is this module's to make: only the game knows what identity survives what. The most important code here is the refusal guard, and it is deliberately conservative. Core's contract is that module unavailability becomes staleness and never emptiness, and this module is the only thing that can honour it -- an empty array from here reads as an authoritative "there are none", and core archives Teams and departs members from an authoritative answer. Three states refuse: no uo-link configured, the integration disabled, and the socket not connected. **The third is the one worth arguing about.** The board is durable and survives an outage, so serving it while disconnected looks harmless. It is not: core cannot tell a board five minutes stale from one five days stale, and a complete answer licenses destruction. There is a test named for that. A fourth refusal has no equivalent anywhere else: a guild whose roster has not arrived. Protocol 4's roster comes on its own frames, separately from the `guild.update` that creates the board row, so there is a real window where a 155-member guild has zero roster rows. The board's own `members` count is the only thing that distinguishes "the roster is late" from "this guild is empty", and it is checked -- with the count in the refusal message, because it is the evidence. The other side is tested too: when the board says zero, an empty roster is the truth and withholding it would freeze a disbanding guild's membership forever. Two limitations, both honest and both in the code as comments: - **`rankLabel` is null.** The wire's roster member is the standard actor object (`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank. Inventing a label from the leader flag would be core displaying something this module made up. - **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from `GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank on the wire, so the only leadership visible here is the board's single `leader_serial`. Raising it to the full set is a protocol change, not something this module can fix. `online` comes from `shard_online` rather than the roster, which carries no per-member presence and only a board-level count -- the same source the public "who's online" surface already uses. `userId` prefers the roster's own `web_id` (what the shard asserted at roster time) and falls back to the `shard_account_links` join for a member whose row predates their link; resolving it here rather than in core is the contract, since core reading `shard_account_links` would be core naming a module's table. `coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor. 18 provider tests plus two on the entry point: that all three methods are registered, and that registration performs no query. The second matters because register() runs while core's app.js is still being required with the pool pointed at a dead port, which both routeManifest.js and swagger.js depend on. `fakeApi` gained `registerTeamProvider` with the same `once` rule core applies -- one provider per deployment, so a second registration has to fail here too rather than passing a shape core rejects at load. 411 -> 413 tests, all passing. Refs docs/website/TEAMS.md §2.3, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>queryfrom the core facade, not acore.dbthat does not existThe Team provider's db layer built its query helper as `core.db.query(...)`. The facade has no `db` member -- every other *.db.js in this module destructures `query` from it directly -- so every call threw `Cannot read properties of undefined (reading 'query')`. The failure mode is the bad part. That throw is caught by the provider's own error handling and turned into `{ ok: false, reason: 'roster unreadable: …' }`, which is a perfectly valid refusal -- so core would have accepted it, held the projection it had, and reported staleness. A provider that answers correctly and never returns data, forever, with nothing in any log louder than a warning. Invisible to the unit tests because they stub every db function, so the helper was never called. Found by running a real roster frame through the ingest and then asking the provider what it saw, against the real database. Co-Authored-By: Claude <noreply@anthropic.com>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>Two lines only core cannot supply for itself. `uo.guild.header` is a third declared slot, at the top of the page, for core's per-Team notification control. A third rather than a corner of the feed because a slot holds one component and the first fill wins: the control is an action ON this page and the other two are content IN it, and separate slots are what let this module say so. `pageUrlTemplate` tells core where a guild page actually is. Teams are a contract primitive with no core surface — core owns the tables and the access rules, this module owns the word "guild" and therefore the page — which leaves core unable to write a link to one. A notification email that cannot take you to the thread it is about is most of the way to useless. Core substitutes `{externalId}` and does nothing else with it; a template naming its own host is refused at registration. Co-Authored-By: Claude <noreply@anthropic.com>