feat(guilds): a UO guild is a Team (Teams cutover 5/6) #16

Merged
whitlocktech merged 22 commits from edge into main 2026-08-19 09:02:40 +00:00

22 Commits

Author SHA1 Message Date
fe176920c5 ci(core-ref): pin to the Teams cutover, not a phase 3 edge sha
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / frozen-manifest (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 8m40s
The pin said "edge @ Teams phase 3" and edge is about to stop existing. More to
the point, this module has since gained the Team provider, three declared slots,
the /guild command and the contribution names on those slots - none of which the
pinned core knew about, so the frozen-manifest job has been proving this module
against a core older than half of it.

routes.manifest.json does not move: the job compares core-with-module against
core-without-module, so core's own Teams routes cancel out and what is left is
this module's 73, unchanged. Verified locally against the cutover core before
moving the pin rather than after.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:00:01 -05:00
d98f0c1a3d Merge pull request 'feat(guilds): name the core contribution each declared slot wants' (#15) from feature/teams-slot-contributions into edge
Reviewed-on: #15
2026-08-19 06:19:52 +00:00
1a13f680f5 feat(guilds): name the core contribution each declared slot wants
Some checks failed
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Failing after 47s
PR Checks / server-tests (pull_request) Failing after 13m23s
Core no longer fills a slot by name - it offers a contribution and the module
that owns the page says where each one goes (MODULE_API 1.6.0, amended). The
three slot names are unchanged and stay this module's own vocabulary; what is
new is the second argument saying which of core's three contributions belongs
in each place.

Nothing here worked differently before. The change is for every game that is
not this one: core used to fill the literal name uo.guild.detail, so a second
module declaring a place under its own id got an empty page and no error.

The registration fake gained the same validation core does, including the
contribution catalogue - written down rather than imported, since this suite
runs against the built chunk with no core in the process, which makes it a
claim about core that has to be re-read when core's list changes.

42 client tests, 437 server tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:15:49 -05:00
7d0378842b Merge pull request 'feat(guilds): /guild — the module's own chat command' (#14) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #14
2026-08-19 00:16:11 +00:00
466842c6f2 fix(guilds): do not offer linking where linking cannot reach
Some checks failed
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 35s
Found on the live rig, with the shard's guild feature gated to staff: the
refusal still read "link your account — this shard shows guild information to
linked players". Signing in reaches `logged_in` and linking a game account
reaches `player`; `staff` and `admin` are roles an operator grants, and no
amount of linking earns them. Inviting someone to do something that changes
nothing is worse than plainly saying no.

Also drops the host name from the list embed's title. `ctx.site` carries a base
URL and no brand name, so naming the deployment there could only ever mean
printing its hostname into a title on the shard's own Discord server.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:22 -05:00
2d1d91e372 feat(guilds): /guild, the module's own chat command
The first command through `api.registerSlashCommands` (MODULE_API 1.6.0,
TEAMS.md §7.1). The definition and the handler both live here; the bot pulls the
definition and runs no line of this module.

`/guild` and not `/team`, deliberately. Core does not own the word for a Team —
that is what deleted its Team pages in phase 3 — so it does not publish the noun
in a channel either. Core ships the dispatcher and zero commands.

The audience rungs are re-resolved in the handler rather than assumed: a shard
that gates guilds to staff does not become public because the question arrived
over Discord. The provider's own staleness guard is honoured too, so a stale
board answers "not connected" instead of reporting what it still holds, and
`resolveUserId` is exported rather than copied so "linked" means here what it
means on the roster.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:45 -05:00
990a50b491 Merge pull request 'feat(guilds): a third place on the guild page, and where that page lives' (#13) from feature/teams-phase6-notifications into edge
Reviewed-on: #13
2026-08-18 23:10:02 +00:00
c57310c505 feat(guilds): a third place on the guild page, and where that page lives
All checks were successful
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 34s
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>
2026-08-18 14:35:36 -05:00
7ce78e303c Merge pull request 'feat(guilds): declare a second place on the guild page, for core's forum' (#12) from feature/teams-phase4-forum-slot into edge
Reviewed-on: #12
2026-08-18 14:17:46 +00:00
46e3f5a127 ci(core-ref): bump the pin past registerTeamProvider
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 42s
`frozen-manifest` has been failing since Teams phase 2, on this PR and on #11
before it, for a reason that has nothing to do with either: the pinned core
(website#140, the module-system de-UO slice) predates `api.registerTeamProvider`,
which this module has called since phase 1 of its Teams work. The module therefore
fails to LOAD in the pinned checkout — "api.registerTeamProvider is not a function"
— and a module that does not load adds no routes, which the job correctly reports
as the module having removed everything it serves.

So the red was real and was pointing at the pin, exactly as the pin's own comment
says it should: core moves for reasons that have nothing to do with this module,
and a bump is a deliberate commit saying which core the module was last proved
against.

Bumped to `edge` at Teams phase 3 (website#152) — the first core that has both
`registerTeamProvider` and the roster projection this module now implements.
Reproduced the whole job locally against that core: core's own manifest is current
at the new pin, the module loads, and the difference is 73 routes, all documented.
`routes.manifest.json` is unchanged and needed no regeneration, which is the
expected result for a client-only change.

Not bumped to the phase 4 core, deliberately: that is website#153 and is not merged
yet. Nothing in this module needs it — the second slot is a client-side
declaration, invisible to the route manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:31:58 -05:00
9d0a197008 feat(guilds): declare a second place on the guild page, for core's forum
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m40s
The mirror of the activity feed, one phase later. Core owns the Team forum —
membership, manual grants and the member/guest split are all core's rules, and a
module reimplementing any of them would be reimplementing a security boundary — but
core publishes no Team page, because it does not own the word "guild". So this
module declares the place and core puts the forum in it.

TWO declarations rather than one, and that is the interesting part. A slot holds one
component and the first fill wins, so folding the forum into `uo.guild.detail`
alongside the feed would hand core the decision about where each of its two
contributions sits on a page this module owns. Separate slots also keep them
independent: with the forum switched off, the feed renders exactly as before.

The registration test now asserts the set of declared slots and that EVERY one of
them is rendered by the page that owns it, rather than naming a single slot twice.
A slot nothing renders is a slot core fills into the void.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:37 -05:00
eb30e4ae37 Merge pull request 'feat(guilds): the roster projection, a guild detail page, and the slot core fills' (#11) from feat/teams-phase3-projection-slot into edge
Reviewed-on: #11
2026-08-18 02:12:18 +00:00
dda0e32dd3 feat(guilds): a guild detail page, and the slot core puts the feed in
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Failing after 33s
PR Checks / server-tests (pull_request) Successful in 8m46s
The module's half of the org lead's correction: Teams is the contract, guilds
are the presentation, and the presentation is this module's.

Adds `/uo/guilds/:id` — the detail view the board never had — with the roster
from this module's OWN board, which is the same data it answers core's Team
provider from. Reading core's projection of our own answer back would be a round
trip through a staler copy of it.

The page declares `uo.guild.detail` and core fills it with the Team activity
feed. That is the one part of this page core cannot hand over: only core can
resolve whether the viewer is inside the Team, and the public/members split on
that feed is a security boundary. The guild is named in OUR terms — core maps
its own Team from the module id and the external id — so this module never holds
core's row id or slug.

`TeamOverviewStrip` is deleted with the core Team page it filled.
`team.member.row` is not declared here either: the useful thing to put in a
roster row is a link to the character behind it, and nothing core could supply
identifies one.

`GET /public/shard/guilds/:id` backs the page, gated and projected through the
same `guilds` feature as the board — so an operator who raises that audience
raises this too, and the locked acct/webId fields never survive below admin. A
roster is where those appear in bulk, which makes this the endpoint where
getting the projection wrong would matter most.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:58:30 -05:00
d4aa5ade12 feat(teams): project rosters by audience rung, and add to the Team page
The module's half of TEAMS.md phase 3.

`projectRoster` is the optional fourth provider method and the only one core
calls on a request path. Core holds the roster and owns its public shape; the
question that is this module's is who is allowed to look, because the audience
rungs and their configuration live here.

The answer is all-or-nothing, which is the honest translation rather than a
shortcut: a rung is a property of the FEATURE, and there is no configuration in
which some members of a guild are public and others are not.

The refusal semantics INVERT here, and the tests say so. For the other three
methods a refusal means "change nothing" and an empty array would be
destructive. Core fails CLOSED on this one, so the dangerous answer is the
opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff. Every path that cannot reach a
confident answer refuses, including the catch.

The anonymous case is answered directly rather than by handing `viewerLevel` a
synthetic request. Given one with no `req.user` it falls through to
`auth.getUserFromRequest`, which expects real cookies and throws on a fake — and
that throw would have become a refusal, so every anonymous visitor would have
been served an empty roster on a shard whose guilds are public. Caught by the
tests, not by reading.

`team.overview` gets a live population reading beside core's stored one. Core's
number comes from the last roster sync and is coarse by construction; this is
the `presence.online` feed this module already holds. It is explicitly not a
per-Team presence figure — the shard publishes a global aggregate and no
per-guild breakdown exists on the wire, so claiming one would be inventing a
number — and it renders nothing at all when it has nothing true to say.

`team.member.row` is left unfilled. The useful thing to put there is a link to
the character behind a row, and the props core can supply do not identify one:
the member key and the site account id are withheld from every public roster.
An empty cell beats a guess.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:16:22 -05:00
0d618599cf Merge pull request 'feat(teams): ingest guild rank, and report every leader rather than one' (#10) from feat/protocol4-guild-rank into edge
Reviewed-on: #10
2026-08-17 22:48:54 +00:00
76b2321f25 Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:48:20 +00:00
99d1ca25a7 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>
2026-08-17 17:42:32 -05:00
c6929c6bae fix(teams): take query from the core facade, not a core.db that does not exist
Some checks failed
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Failing after 40s
The 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>
2026-08-17 17:41:22 -05:00
51e58104bf Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:19:01 +00:00
268449f2a6 feat(teams): answer core's Team provider from the guild board
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 44s
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>
2026-08-17 15:31:58 -05:00
e93361aa48 Merge pull request 'feat(shard): ingest guild rosters and departures (protocol 4)' (#8) from feat/teams-phase1-guild-roster into edge
Reviewed-on: #8
2026-08-17 19:29:05 +00:00
2fa4d87a40 feat(shard): ingest guild rosters and departures (protocol 4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00