From 2fa4d87a400bbad171f87a01e0f9228d6e85d6d3 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 12:57:26 -0500
Subject: [PATCH 01/13] feat(shard): ingest guild rosters and departures
(protocol 4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.gitea/workflows/pr-checks.yml | 9 ++-
server/db/purge.sql | 1 +
server/db/schema.sql | 28 ++++++++
server/model/shardState/shardState.db.js | 36 ++++++++++
server/model/shardState/shardState.model.js | 79 ++++++++++++++++++++-
server/test/shardIngest.guildRoster.test.js | 75 +++++++++++++++++++
server/test/shardVisibility.test.js | 56 ++++++++++++++-
server/utils/shardIngest.js | 16 +++++
server/utils/shardVisibility.js | 7 ++
9 files changed, 302 insertions(+), 5 deletions(-)
create mode 100644 server/test/shardIngest.guildRoster.test.js
diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml
index 052c666..08cb264 100644
--- a/.gitea/workflows/pr-checks.yml
+++ b/.gitea/workflows/pr-checks.yml
@@ -61,12 +61,19 @@
#
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
# Node — no Docker socket, no database.
+#
+# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there
+# first, so gating only the `main` hop would run these checks for the first time
+# at the cutover — the one moment a red build is most expensive to discover. This
+# is the same call `RunicGateway/installer` made for the same reason, and it was
+# taken here after a nine-PR Android workstream landed on an ungated `edge` with
+# no CI at all. Adding a branch to the `branches:` list is the whole change.
name: PR Checks
on:
pull_request:
- branches: [main]
+ branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:
diff --git a/server/db/purge.sql b/server/db/purge.sql
index 7b6cc48..86d5679 100644
--- a/server/db/purge.sql
+++ b/server/db/purge.sql
@@ -45,6 +45,7 @@ DROP TABLE IF EXISTS `shard_ruleset`;
DROP TABLE IF EXISTS `shard_presence`;
DROP TABLE IF EXISTS `shard_governor_terms`;
DROP TABLE IF EXISTS `shard_governors`;
+DROP TABLE IF EXISTS `shard_guild_members`;
DROP TABLE IF EXISTS `shard_guilds`;
DROP TABLE IF EXISTS `shard_pages`;
DROP TABLE IF EXISTS `shard_champs`;
diff --git a/server/db/schema.sql b/server/db/schema.sql
index ae263e4..7d56898 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -220,6 +220,34 @@ CREATE TABLE IF NOT EXISTS shard_guilds (
INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- Guild membership (Protocol 4). One row per member per guild, replaced on
+-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY
+-- members a guild had, so this table has no pre-4 equivalent and the Guilds page
+-- could show a count but never a roster.
+--
+-- `acct` / `web_id` are the site-identity fields and are stored because the
+-- sidecar forwards them; they are NOT public. shardVisibility locks any key that
+-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected
+-- roster loses them below that rung — storing them here is what lets a linked
+-- member be matched to a site user at all.
+--
+-- A roster over the shard's per-frame cap arrives in several frames, so rows are
+-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first;
+-- see upsertGuildRoster.
+CREATE TABLE IF NOT EXISTS shard_guild_members (
+ guild_id INT NOT NULL,
+ serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5"
+ name VARCHAR(120) NULL,
+ 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,
+ 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)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
diff --git a/server/model/shardState/shardState.db.js b/server/model/shardState/shardState.db.js
index 5bdacfe..3c0233c 100644
--- a/server/model/shardState/shardState.db.js
+++ b/server/model/shardState/shardState.db.js
@@ -171,6 +171,37 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
+// ── Guild membership (Protocol 4) ──────────────────────────────────────────
+const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, t'
+
+// Upsert rather than plain insert: a roster frame can be redelivered (the /history
+// backfill replays stored frames on every reconnect), and a redelivery must be a
+// no-op rather than a duplicate-key error.
+const upsertGuildMembers = (rows) => {
+ if (!rows.length) return Promise.resolve()
+ const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
+ const params = rows.flatMap((r) => [r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player, r.t])
+ return query(
+ `INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
+ ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
+ web_id = VALUES(web_id), is_player = VALUES(is_player), t = VALUES(t)`,
+ params,
+ )
+}
+
+const clearGuildMembers = (guildId) =>
+ query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId])
+
+const removeGuildMember = (guildId, serial) =>
+ query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial])
+
+const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members')
+
+const listGuildMembers = (guildId) =>
+ query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [
+ guildId,
+ ])
+
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -342,6 +373,11 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
+ upsertGuildMembers,
+ clearGuildMembers,
+ removeGuildMember,
+ clearAllGuildMembers,
+ listGuildMembers,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,
diff --git a/server/model/shardState/shardState.model.js b/server/model/shardState/shardState.model.js
index 10a3ba2..afde59e 100644
--- a/server/model/shardState/shardState.model.js
+++ b/server/model/shardState/shardState.model.js
@@ -340,8 +340,80 @@ async function upsertGuild(ev) {
})
}
-const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
-const clearGuilds = () => db.clearGuilds()
+const removeGuild = async (id) => {
+ if (id == null) return
+ await db.removeGuild(id)
+ await db.clearGuildMembers(id)
+}
+const clearGuilds = async () => {
+ await db.clearGuilds()
+ await db.clearAllGuildMembers()
+}
+
+// ── Guild membership (Protocol 4) ──────────────────────────────────────────
+// Apply one guild.roster frame.
+//
+// A roster larger than the shard's per-frame cap arrives as several frames
+// carrying seq/more/total. The sidecar reassembles them for its OWN board, but the
+// live WebSocket feed and the /history backfill both carry the individual frames,
+// so this ingest sees them unreassembled and has to cope.
+//
+// It copes without buffering, because a table can express what a single JSON column
+// could not: the frame carrying seq 0 clears the guild first and every frame then
+// upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame
+// — the /history backfill replays stored frames on every reconnect — is idempotent
+// rather than a duplicate-key error.
+//
+// The cost is a brief window during a multi-frame update where the table holds part
+// of a roster. That is acceptable for a projection that is already only as fresh as
+// a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close
+// it would duplicate the sidecar's reassembly for a sub-second inconsistency.
+async function upsertGuildRoster(ev) {
+ if (!ev || ev.id == null) return
+
+ const seq = Number.isFinite(ev.seq) ? ev.seq : 0
+ const members = Array.isArray(ev.members) ? ev.members : []
+
+ // seq 0 begins a roster and supersedes whatever was held for this guild.
+ if (seq === 0) await db.clearGuildMembers(ev.id)
+
+ const rows = members
+ .filter((m) => m && m.serial)
+ .map((m) => ({
+ guild_id: ev.id,
+ serial: m.serial,
+ name: m.name ?? null,
+ acct: m.acct ?? null,
+ web_id: Number.isFinite(m.webId) ? m.webId : null,
+ is_player: m.player ? 1 : 0,
+ t: Number.isFinite(ev.t) ? ev.t : null,
+ }))
+
+ await db.upsertGuildMembers(rows)
+}
+
+// A single departure (guild.leave). Advisory: the shard re-emits the full roster
+// whenever the member set changes, so the table would converge on the next frame
+// even if this were dropped. Applying it makes the change visible immediately
+// instead of at the end of the sweep that produced it.
+async function removeGuildMember(ev) {
+ if (!ev || ev.id == null || !ev.who) return
+ await db.removeGuildMember(ev.id, ev.who)
+}
+
+// The membership roster for one guild, in the wire shape the projection expects
+// (an array of actor objects), so shardVisibility strips acct/webId by the same
+// rule it applies to guild.leader.
+async function listGuildMembers(guildId) {
+ const rows = await db.listGuildMembers(guildId)
+ return rows.map((r) => ({
+ serial: r.serial,
+ name: r.name,
+ ...(r.acct == null ? {} : { acct: r.acct }),
+ ...(r.web_id == null ? {} : { webId: r.web_id }),
+ player: !!r.is_player,
+ }))
+}
function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
@@ -620,6 +692,9 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
+ upsertGuildRoster,
+ removeGuildMember,
+ listGuildMembers,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,
diff --git a/server/test/shardIngest.guildRoster.test.js b/server/test/shardIngest.guildRoster.test.js
new file mode 100644
index 0000000..9981480
--- /dev/null
+++ b/server/test/shardIngest.guildRoster.test.js
@@ -0,0 +1,75 @@
+// Protocol 4 membership routing: guild.roster (board state, possibly chunked) and
+// guild.leave (a real-time departure, logged like its guild.join counterpart).
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const shardIngest = require('../utils/shardIngest')
+
+function makeDeps() {
+ const calls = { roster: [], memberRemove: [], appended: [], broadcast: [] }
+ const noop = async () => {}
+ return {
+ calls,
+ shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
+ shardState: {
+ upsertGuildRoster: async (ev) => { calls.roster.push(ev) },
+ removeGuildMember: async (ev) => { calls.memberRemove.push(ev) },
+ upsertGuild: noop, removeGuild: noop,
+ clearOnline: noop, upsertOnline: noop, setOffline: noop,
+ addEconomySample: noop,
+ },
+ shardLinks: { removeByAccount: noop },
+ uoLinkConfig: { recordStatus: noop },
+ broadcast: (ev) => { calls.broadcast.push(ev) },
+ pushDispatch: async () => {},
+ log: { warn() {}, info() {}, error() {} },
+ }
+}
+
+beforeEach(() => shardIngest.reset())
+
+test('guild.roster routes to upsertGuildRoster and is NOT logged', async () => {
+ // It is board state like guild.update, and the one fat frame on the wire —
+ // logging it would put a full membership snapshot in shard_events on every
+ // membership change.
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(
+ { kind: 'guild.roster', id: 7, seq: 0, more: false, total: 2,
+ members: [{ serial: '0x1', name: 'Ada' }, { serial: '0x2', name: 'Bo' }], t: 1 },
+ deps,
+ )
+
+ assert.equal(deps.calls.roster.length, 1)
+ assert.equal(deps.calls.roster[0].id, 7)
+ assert.equal(deps.calls.roster[0].members.length, 2)
+ assert.equal(r.logged, false)
+})
+
+test('every frame of a chunked roster reaches the model, seq intact', async () => {
+ // The sidecar reassembles for its own board, but the live feed and the /history
+ // backfill both carry individual frames — so the model must see each one with its
+ // seq, which is what tells it whether to clear the guild first.
+ const deps = makeDeps()
+
+ for (const [seq, more, serial] of [[0, true, '0x1'], [1, true, '0x2'], [2, false, '0x3']]) {
+ await shardIngest.ingest(
+ { kind: 'guild.roster', id: 7, seq, more, total: 3, members: [{ serial, name: serial }], t: 1 },
+ deps,
+ )
+ }
+
+ assert.deepEqual(deps.calls.roster.map((e) => e.seq), [0, 1, 2])
+ assert.deepEqual(deps.calls.roster.map((e) => e.more), [true, true, false])
+})
+
+test('guild.leave is logged and broadcast, like guild.join', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(
+ { kind: 'guild.leave', id: 7, name: 'The Cartographers', who: '0x2', t: 2 }, deps)
+
+ assert.equal(r.logged, true)
+ assert.equal(deps.calls.appended.length, 1)
+ assert.equal(deps.calls.appended[0].kind, 'guild.leave')
+ assert.equal(deps.calls.broadcast.length, 1)
+ assert.deepEqual(deps.calls.memberRemove.map((e) => e.who), ['0x2'])
+})
diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js
index 6a49368..3e53c80 100644
--- a/server/test/shardVisibility.test.js
+++ b/server/test/shardVisibility.test.js
@@ -116,6 +116,52 @@ test('acct and webId are stripped below admin regardless of feature config', ()
assert.equal(asAdmin.leader.webId, '42')
})
+test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
+ // A roster is the first frame where the locked fields appear inside an ARRAY of
+ // actors rather than one nested actor. The walker recurses into arrays, so this
+ // should already hold — this test is here because it is the difference between a
+ // public Guilds page listing character names and one publishing 150 account names.
+ const config = visibility.compileDefaults()
+ const frame = {
+ kind: 'guild.roster',
+ id: 7,
+ total: 3,
+ seq: 0,
+ more: false,
+ members: [
+ { serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
+ { serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
+ { serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
+ ],
+ }
+
+ for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
+ const out = visibility.projectFeature('guilds', frame, level, config)
+ assert.equal(out.members.length, 3, `${level} still sees every member`)
+ assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
+ for (const m of out.members) {
+ assert.equal('acct' in m, false, `${level} must not see a member's acct`)
+ assert.equal('webId' in m, false, `${level} must not see a member's webId`)
+ }
+ }
+
+ const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
+ assert.equal(asAdmin.members[0].acct, 'ada_acct')
+ assert.equal(asAdmin.members[0].webId, '11')
+})
+
+test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
+ // Rule 2 fails an unmapped kind closed. That is the right default, but for these
+ // two it would silently keep the public Guilds page from ever seeing a roster.
+ const config = visibility.compileDefaults()
+ for (const kind of ['guild.roster', 'guild.leave']) {
+ assert.equal(
+ visibility.kindVisibleTo(kind, 'anonymous', config), true,
+ `${kind} should reach an anonymous viewer under the default guilds config`,
+ )
+ }
+})
+
test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
@@ -308,10 +354,16 @@ const PRE_V3_PUBLIC_KINDS = [
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
-test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
+// v4 adds guild membership. Both ride the existing `guilds` feature, which is
+// already anonymous, so they join the public set — carrying character names and
+// serials, never acct/webId, which the locked-field rules strip by suffix even
+// inside the roster's member array (see the roster test above).
+const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
+
+test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
- [...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
+ [...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
)
})
diff --git a/server/utils/shardIngest.js b/server/utils/shardIngest.js
index a64d902..b21f6f1 100644
--- a/server/utils/shardIngest.js
+++ b/server/utils/shardIngest.js
@@ -45,6 +45,12 @@ const LOGGED_KINDS = new Set([
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
+ // Protocol 4: the departure counterpart to guild.join, and logged for the same
+ // reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately
+ // stays out: it is board state like guild.update, and it is the one fat frame on
+ // the wire (~69 bytes per member), so logging it would bloat shard_events with
+ // a full membership snapshot on every membership change.
+ 'guild.leave',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
@@ -187,6 +193,16 @@ async function applyStateChange(event, deps) {
case 'guild.remove':
await shardState.removeGuild(event.id)
return
+ // Protocol 4: membership. A roster arrives in one frame for any realistic
+ // guild and in several for one over the shard's cap — upsertGuildRoster
+ // handles both. guild.leave is advisory; the next roster would converge
+ // anyway, but applying it shows the departure at once.
+ case 'guild.roster':
+ await shardState.upsertGuildRoster(event)
+ return
+ case 'guild.leave':
+ await shardState.removeGuildMember(event)
+ return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)
diff --git a/server/utils/shardVisibility.js b/server/utils/shardVisibility.js
index 15ac19c..a098569 100644
--- a/server/utils/shardVisibility.js
+++ b/server/utils/shardVisibility.js
@@ -163,6 +163,13 @@ const KIND_FEATURE = new Map(
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
+ // Protocol 4. Both carry actor data — a roster is an array of actor objects
+ // and guild.leave names a serial — so they ride the same `guilds` feature and
+ // the same locked-field rules: `acct`/`webId` inside a roster member are
+ // stripped below admin by suffix, exactly as `guild.leader.acct` already is.
+ // Without these two lines rule 2 would fail them closed to admin-only.
+ 'guild.roster': 'guilds',
+ 'guild.leave': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',
--
2.49.1
From 268449f2a6739a4a568159879651cb0db58bd7eb Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 15:31:58 -0500
Subject: [PATCH 02/13] feat(teams): answer core's Team provider from the guild
board
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
server/index.js | 10 +
server/model/teamProvider/teamProvider.db.js | 66 +++++
.../model/teamProvider/teamProvider.model.js | 182 +++++++++++++
server/test/_fakes.js | 5 +
server/test/entry.test.js | 29 +++
server/test/teamProvider.test.js | 246 ++++++++++++++++++
6 files changed, 538 insertions(+)
create mode 100644 server/model/teamProvider/teamProvider.db.js
create mode 100644 server/model/teamProvider/teamProvider.model.js
create mode 100644 server/test/teamProvider.test.js
diff --git a/server/index.js b/server/index.js
index f62a4b7..96c91fc 100644
--- a/server/index.js
+++ b/server/index.js
@@ -45,6 +45,7 @@ module.exports = function register(ctx, api) {
const shardStreams = require('./config/shardStreams')
const townCrierLeg = require('./utils/shardAnnounce')
+ const teamProvider = require('./model/teamProvider/teamProvider.model')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -86,6 +87,15 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
+ // Teams: a UO guild is a Team, and this module is the authoritative source of
+ // them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
+ // everything about what a guild IS stays here.
+ //
+ // Registration is a claim, not a call — nothing below runs until core
+ // reconciles, which is after `onBoot`. That matters because every method reads
+ // the database, and registration must not.
+ api.registerTeamProvider(teamProvider)
+
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
diff --git a/server/model/teamProvider/teamProvider.db.js b/server/model/teamProvider/teamProvider.db.js
new file mode 100644
index 0000000..49024cc
--- /dev/null
+++ b/server/model/teamProvider/teamProvider.db.js
@@ -0,0 +1,66 @@
+// SQL behind the Team provider — three questions core asks, answered from the
+// guild board and the roster Protocol 4 put there.
+//
+// Every statement reads only THIS module's tables. Core's Team tables are
+// core-internal (docs/website/TEAMS.md §10.3) and this module must never name
+// one, even though it is what fills them.
+
+const core = require('../../core')
+
+const query = (...args) => core.db.query(...args)
+
+/**
+ * The guild board — one row per guild the shard has told us about.
+ *
+ * `members`/`online` here are the COUNTS `guild.update` carries; the roster is a
+ * separate table (Protocol 4). Both are read, because a count is what the shard
+ * asserts and a roster is what it enumerated, and they can legitimately disagree
+ * for the moment between a membership change and the sweep that reports it.
+ */
+const listGuilds = () =>
+ query(
+ `SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
+ FROM shard_guilds ORDER BY name ASC`,
+ )
+
+const findGuild = (id) =>
+ query(
+ `SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
+ FROM shard_guilds WHERE id = ? LIMIT 1`,
+ [id],
+ )
+
+/**
+ * One guild's roster, with the site link and live presence folded in.
+ *
+ * Two LEFT JOINs, both deliberate:
+ *
+ * - `shard_account_links` resolves `user_id` HERE rather than in core, because
+ * this module owns that table and a core that read it would be core naming a
+ * module's table by name (§2.3). It is also why a freshly linked account
+ * appears as linked on the next reconcile rather than needing core to know
+ * anything about linking.
+ * - `shard_online` is how a member's `online` is answered at all. The roster
+ * frame does not carry it — the wire's member is the standard actor object
+ * (`serial`, `name`, `player`, `acct?`, `webId?`), and the board's `online` is
+ * a count, not a set. Presence therefore comes from the online table, which
+ * is the same source the public "who's online" surface already uses.
+ *
+ * `web_id` on the roster row is preferred over the link table when present: it is
+ * what the shard itself asserted at roster time, and the join is the fallback for
+ * a member whose row predates their link.
+ */
+const listGuildMembers = (guildId) =>
+ query(
+ `SELECT m.serial, m.name, m.acct, m.web_id, m.is_player,
+ l.user_id AS linked_user_id,
+ (o.serial IS NOT NULL) AS is_online
+ FROM shard_guild_members m
+ LEFT JOIN shard_account_links l ON l.account = m.acct
+ LEFT JOIN shard_online o ON o.serial = m.serial
+ WHERE m.guild_id = ?
+ ORDER BY m.name ASC`,
+ [guildId],
+ )
+
+module.exports = { listGuilds, findGuild, listGuildMembers }
diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js
new file mode 100644
index 0000000..a18888f
--- /dev/null
+++ b/server/model/teamProvider/teamProvider.model.js
@@ -0,0 +1,182 @@
+// ── module-uo's Team provider ──────────────────────────────────────────────
+//
+// The three questions core asks this module about Teams
+// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3).
+// A UO guild is a Team; this file is the whole of the translation.
+//
+// **Every method returns an envelope, and answering `{ ok: false }` is a normal
+// outcome, not a failure to handle.** Core's contract is that module
+// unavailability becomes staleness and never emptiness, and the only way this
+// module can say "I cannot answer" is to say so — an empty array would be read as
+// an authoritative "there are none", which during a cold start is how every
+// roster on the site gets emptied. So the guard below is the most important code
+// in the file, and it is deliberately conservative: **an unreachable or
+// never-connected sidecar refuses, rather than reporting the board it happens to
+// still hold.**
+//
+// The board IS durable and would survive a sidecar outage, which is exactly what
+// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell
+// a board that is five minutes stale from one that is five days stale, and it
+// makes destructive decisions — archiving Teams, departing members — from a
+// complete answer. Reporting a stale board as authoritative would license those.
+
+const core = require('../../core')
+const db = require('./teamProvider.db')
+const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
+const uoLinkSocket = require('../../utils/uoLinkSocket')
+
+const log = core.logger('teams')
+
+/** A refusal, in the shape core reads (§2.3). */
+const refuse = (reason) => ({ ok: false, reason })
+
+/**
+ * Is the bridge in a state where the board can be trusted as current?
+ *
+ * The board is only as good as the socket that fills it. Three states refuse, and
+ * they are asked in this order because each is a different thing being wrong:
+ *
+ * - **no uo-link configured** — there is no shard behind this website at all;
+ * - **the integration is disabled** — an admin turned it off, and the board is
+ * frozen at whatever it held;
+ * - **the socket is not connected** — the board is a snapshot of unknown age.
+ *
+ * The in-process socket state is preferred over the persisted status column,
+ * which is written on transitions: a process that has just started has not
+ * transitioned yet, so the column can still say `connected` from the last run
+ * while this process has never opened a socket.
+ */
+async function boardIsCurrent() {
+ const config = await uoLinkConfig.getSafe()
+ if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' }
+ if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' }
+
+ const state = uoLinkSocket.getState()
+ if (!state || !state.connected) {
+ return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' }
+ }
+ return { ok: true }
+}
+
+/**
+ * `getTeams()` — every guild on the board.
+ *
+ * `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a
+ * guild in-game keeps the id, so core sees "an id whose name changed" and applies
+ * its rename rule (archive plus create). That mapping is this module's to make —
+ * only the game knows what identity survives what (§10.5).
+ *
+ * `meta` carries the alliance, opaquely. Core stores and displays it and never
+ * branches on it, which is what lets a UO concept reach a Team page without core
+ * acquiring an opinion about alliances.
+ */
+async function getTeams() {
+ const ready = await boardIsCurrent()
+ if (!ready.ok) return refuse(ready.reason)
+
+ try {
+ const rows = await db.listGuilds()
+ return {
+ ok: true,
+ complete: true,
+ teams: rows.map((row) => ({
+ externalId: String(row.id),
+ name: row.name,
+ abbr: row.abbr || null,
+ meta: row.alliance ? { alliance: row.alliance } : null,
+ })),
+ }
+ } catch (err) {
+ log.warn('getTeams failed', { message: err.message })
+ return refuse(`guild board unreadable: ${err.message}`)
+ }
+}
+
+/**
+ * `getTeamMembers(externalId)` — one guild's roster.
+ *
+ * **A guild with no roster rows is refused, not reported empty**, unless the board
+ * itself says the guild has no members. Protocol 4's roster arrives on its own
+ * frames, separately from the `guild.update` that creates the board row, so there
+ * is a real window — a fresh guild, or a website that connected between the two —
+ * where core would otherwise be told authoritatively that a 155-member guild has
+ * nobody in it. The board's own `members` count is what distinguishes the two,
+ * and it is the only thing that can.
+ */
+async function getTeamMembers(externalId) {
+ const ready = await boardIsCurrent()
+ if (!ready.ok) return refuse(ready.reason)
+
+ try {
+ const [guild] = await db.findGuild(externalId)
+ if (!guild) return refuse(`guild ${externalId} is not on the board`)
+
+ const rows = await db.listGuildMembers(externalId)
+ if (!rows.length && guild.members > 0) {
+ return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
+ }
+
+ const leaderSerial = guild.leader_serial || null
+ return {
+ ok: true,
+ complete: true,
+ members: rows.map((row) => ({
+ memberKey: row.serial,
+ displayName: row.name || null,
+ // Not on the wire. The roster member is the standard actor object, which
+ // carries no guild rank — see the note at the bottom of this file.
+ rankLabel: null,
+ leader: Boolean(leaderSerial && row.serial === leaderSerial),
+ online: Boolean(row.is_online),
+ userId: resolveUserId(row),
+ })),
+ }
+ } catch (err) {
+ log.warn('getTeamMembers failed', { externalId, message: err.message })
+ return refuse(`roster unreadable: ${err.message}`)
+ }
+}
+
+/**
+ * `getTeamLeaders(externalId)` — who leads the guild.
+ *
+ * **One leader, because that is all the wire carries.** TEAMS.md §2.5 expects
+ * multiple leaders to be the normal case, from `PlayerMobile.GuildRank.Rank >= 4`,
+ * and core supports them — but Protocol 4's roster member is the standard actor
+ * object with no rank field, so the only leadership this module can see is the
+ * board's single `leader_serial` from `guild.update`. Reporting a guessed second
+ * leader would be worse than reporting one honestly.
+ *
+ * Raising this to the full set is a protocol change (rank on the actor object),
+ * not something this file can fix.
+ */
+async function getTeamLeaders(externalId) {
+ const ready = await boardIsCurrent()
+ if (!ready.ok) return refuse(ready.reason)
+
+ try {
+ const [guild] = await db.findGuild(externalId)
+ if (!guild) return refuse(`guild ${externalId} is not on the board`)
+ return { ok: true, leaders: guild.leader_serial ? [guild.leader_serial] : [] }
+ } catch (err) {
+ log.warn('getTeamLeaders failed', { externalId, message: err.message })
+ return refuse(`leadership unreadable: ${err.message}`)
+ }
+}
+
+/**
+ * The site account behind a character, or null.
+ *
+ * `web_id` is what the shard itself asserted when it emitted the roster; the
+ * account-link join is the fallback for a member whose roster row predates their
+ * link. Both are coerced through the same check, because `web_id` arrives from
+ * the wire as a string.
+ */
+function resolveUserId(row) {
+ const fromRoster = Number.parseInt(row.web_id, 10)
+ if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster
+ const fromLink = Number.parseInt(row.linked_user_id, 10)
+ return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
+}
+
+module.exports = { getTeams, getTeamMembers, getTeamLeaders, boardIsCurrent }
diff --git a/server/test/_fakes.js b/server/test/_fakes.js
index 3f13c62..8aaee4d 100644
--- a/server/test/_fakes.js
+++ b/server/test/_fakes.js
@@ -95,6 +95,7 @@ function fakeApi() {
extensions: [],
streams: null,
legs: [],
+ teamProvider: null,
hooks: {},
}
const called = new Set()
@@ -107,6 +108,10 @@ function fakeApi() {
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
+ // MODULE_API 1.6.0. `once` because core holds a single provider per
+ // deployment — a second registration is a collision there, so it has to be
+ // one here too, or this suite would pass a shape core rejects at load.
+ registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index cc4ee2d..91b9249 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -85,6 +85,35 @@ test('every registered stream is namespaced or grandfathered', () => {
}
})
+test('registers a Team provider with all three methods', () => {
+ // Core requires all three: a provider that could list Teams but not their
+ // members would leave core holding Teams it can never populate, which is not
+ // the same as a call that fails. Asserted here so a refactor that drops one
+ // fails in this suite rather than at load on an operator's install.
+ const api = fakeApi()
+ register(fakeCtx(), api)
+
+ const provider = api.record.teamProvider
+ assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
+ for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
+ assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
+ }
+})
+
+test('registration does not call the provider, or touch the database', async () => {
+ // register() runs while core's app.js is still being required, with the pool
+ // pointed at a dead port — routeManifest.js and swagger.js both depend on that.
+ // Registration is a CLAIM; core does not ask anything until it reconciles,
+ // which is after onBoot.
+ const ctx = fakeCtx()
+ let queried = false
+ const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
+ const api = fakeApi()
+
+ register(frozen, api)
+ assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
+})
+
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))
diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js
new file mode 100644
index 0000000..4780b15
--- /dev/null
+++ b/server/test/teamProvider.test.js
@@ -0,0 +1,246 @@
+// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0).
+//
+// The tests that matter here are the REFUSALS. 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 is read as an
+// authoritative "there are none", and core makes destructive decisions from an
+// authoritative answer. Every state where this module cannot honestly claim to
+// know is asserted below, because each one is a plausible place for someone to
+// later "simplify" the guard away and get a plausible-looking empty list.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const core = require('../core')
+
+// The provider reaches the database through core, which is initialised with a ctx
+// in production. A minimal one is enough here — the db layer is stubbed anyway.
+core.init({
+ db: { query: async () => [] },
+ log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
+ moduleId: 'uo',
+})
+
+const db = require('../model/teamProvider/teamProvider.db')
+const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
+const uoLinkSocket = require('../utils/uoLinkSocket')
+const provider = require('../model/teamProvider/teamProvider.model')
+
+const saved = []
+function patch(mod, name, fn) {
+ saved.push([mod, name, mod[name]])
+ mod[name] = fn
+}
+
+// The healthy default: configured, enabled, connected. Each test then breaks only
+// the thing it is about.
+function healthy() {
+ patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
+ patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
+}
+
+const guild = (extra = {}) => ({
+ id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null,
+ members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra,
+})
+
+const member = (extra = {}) => ({
+ serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
+ linked_user_id: null, is_online: 0, ...extra,
+})
+
+beforeEach(healthy)
+afterEach(() => {
+ while (saved.length) {
+ const [mod, name, fn] = saved.pop()
+ mod[name] = fn
+ }
+})
+
+// ── The refusals ───────────────────────────────────────────────────────────
+
+test('no uo-link configured refuses, on all three methods', async () => {
+ patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false }))
+ patch(db, 'listGuilds', async () => { throw new Error('must not be read') })
+
+ for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) {
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /no uo-link configured/)
+ assert.equal(answer.teams, undefined)
+ assert.equal(answer.members, undefined)
+ }
+})
+
+test('a disabled integration refuses rather than reporting a frozen board', async () => {
+ patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false }))
+ const answer = await provider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /disabled/)
+})
+
+test('a disconnected socket refuses, even though the board is still there', async () => {
+ // The tempting mistake, stated as a test: the board is durable and survives an
+ // outage, so serving it looks harmless. Core cannot tell a board five minutes
+ // stale from one five days stale, and it archives Teams and departs members
+ // from a complete answer.
+ patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true }))
+ patch(db, 'listGuilds', async () => [guild()])
+
+ const answer = await provider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /not connected/)
+ assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative')
+})
+
+test('a database error refuses instead of throwing at core', async () => {
+ patch(db, 'listGuilds', async () => { throw new Error('table gone') })
+ const answer = await provider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /table gone/)
+})
+
+test('a guild absent from the board refuses rather than reporting an empty roster', async () => {
+ patch(db, 'findGuild', async () => [])
+ const members = await provider.getTeamMembers('99')
+ assert.equal(members.ok, false)
+ assert.match(members.reason, /not on the board/)
+
+ const leaders = await provider.getTeamLeaders('99')
+ assert.equal(leaders.ok, false)
+})
+
+test('a roster that has not arrived yet refuses — the board count is what tells us', async () => {
+ // Protocol 4's roster arrives 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 no roster rows. Reporting that as an empty roster would
+ // depart every member.
+ patch(db, 'findGuild', async () => [guild({ members: 155 })])
+ patch(db, 'listGuildMembers', async () => [])
+
+ const answer = await provider.getTeamMembers('1')
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /has not arrived yet/)
+ assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence')
+})
+
+test('a guild the board says is genuinely empty reports an empty roster', async () => {
+ // The other side of the same coin: when the board itself says zero, an empty
+ // roster is the truth and withholding it would freeze a disbanding guild's
+ // membership forever.
+ patch(db, 'findGuild', async () => [guild({ members: 0 })])
+ patch(db, 'listGuildMembers', async () => [])
+
+ const answer = await provider.getTeamMembers('1')
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, [])
+})
+
+// ── The good answers ───────────────────────────────────────────────────────
+
+test('a guild becomes a Team keyed on its persistent ServUO id', async () => {
+ // The id survives a rename, which is what lets core apply its rename rule
+ // instead of seeing an unrelated new guild.
+ patch(db, 'listGuilds', async () => [guild()])
+ const answer = await provider.getTeams()
+
+ assert.equal(answer.ok, true)
+ assert.equal(answer.complete, true)
+ assert.deepEqual(answer.teams, [
+ { externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null },
+ ])
+})
+
+test('an alliance rides along as opaque meta', async () => {
+ patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })])
+ const { teams } = await provider.getTeams()
+ assert.deepEqual(teams[0].meta, { alliance: 'The Concord' })
+})
+
+test('the external id is a string, so core never compares a number to one', async () => {
+ patch(db, 'listGuilds', async () => [guild({ id: 42 })])
+ const { teams } = await provider.getTeams()
+ assert.equal(teams[0].externalId, '42')
+})
+
+test('a roster maps to the member shape core expects', async () => {
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [
+ member({ serial: '0x1', name: 'Aldric', is_online: 1 }),
+ member({ serial: '0x2', name: 'Bree', acct: null, is_online: 0 }),
+ ])
+
+ const { members } = await provider.getTeamMembers('1')
+ assert.equal(members.length, 2)
+ assert.equal(members[0].memberKey, '0x1')
+ assert.equal(members[0].displayName, 'Aldric')
+ assert.equal(members[0].online, true)
+ assert.equal(members[0].leader, true, 'matches the board’s leader_serial')
+ assert.equal(members[1].leader, false)
+ assert.equal(members[1].online, false)
+})
+
+test('rankLabel is null, honestly — the wire carries no guild rank', async () => {
+ // The roster member is the standard actor object (serial, name, player, acct?,
+ // webId?). Inventing a rank from the leader flag would be core displaying a
+ // label this module made up.
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [member()])
+ const { members } = await provider.getTeamMembers('1')
+ assert.equal(members[0].rankLabel, null)
+})
+
+test('a member with no account at all is fine and unlinked', async () => {
+ // §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
+ // have no Account, and the local test world contains such mobiles.
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })])
+ const { members } = await provider.getTeamMembers('1')
+ assert.equal(members[0].userId, null)
+})
+
+test('userId comes from the roster’s web_id first, then the link table', async () => {
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [
+ member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins
+ member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback
+ member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither
+ ])
+ const { members } = await provider.getTeamMembers('1')
+ assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time')
+ assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link')
+ assert.equal(members[2].userId, null)
+})
+
+test('web_id arrives as a string from the wire and is coerced', async () => {
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })])
+ const { members } = await provider.getTeamMembers('1')
+ assert.equal(members[0].userId, 42)
+ assert.equal(typeof members[0].userId, 'number')
+})
+
+test('leadership is the board’s single leader — one, and honestly one', async () => {
+ // TEAMS.md §2.5 expects multiple leaders (GuildRank.Rank >= 4) and core
+ // supports them, but Protocol 4 does not put rank on the wire. Raising this to
+ // the full set is a protocol change, not something this file can fix.
+ patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
+ assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1'])
+})
+
+test('a guild with no leader on the board reports none rather than guessing', async () => {
+ patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
+ const answer = await provider.getTeamLeaders('1')
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.leaders, [])
+})
+
+test('an empty board is an authoritative empty list — the shard really has no guilds', async () => {
+ // Distinct from every refusal above: the socket is connected and the board is
+ // readable, so "no guilds" is a fact. Core still quarantines it before acting.
+ patch(db, 'listGuilds', async () => [])
+ const answer = await provider.getTeams()
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.teams, [])
+})
--
2.49.1
From c6929c6baec7629bda567e6aa48a24458cc0de34 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 17:41:22 -0500
Subject: [PATCH 03/13] fix(teams): take `query` from the core facade, not a
`core.db` that does not exist
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
server/model/teamProvider/teamProvider.db.js | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/server/model/teamProvider/teamProvider.db.js b/server/model/teamProvider/teamProvider.db.js
index 49024cc..3608c26 100644
--- a/server/model/teamProvider/teamProvider.db.js
+++ b/server/model/teamProvider/teamProvider.db.js
@@ -5,9 +5,10 @@
// core-internal (docs/website/TEAMS.md §10.3) and this module must never name
// one, even though it is what fills them.
-const core = require('../../core')
-
-const query = (...args) => core.db.query(...args)
+// `query` is destructured from the core facade at require time, like every other
+// *.db.js here. The facade resolves `ctx` per call, so taking it now is safe even
+// though `ctx` does not exist yet when this file is first required.
+const { query } = require('../../core')
/**
* The guild board — one row per guild the shard has told us about.
--
2.49.1
From 99d1ca25a7988a0faeaea4c4717721fd23bd7cb0 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 17:42:32 -0500
Subject: [PATCH 04/13] feat(teams): ingest guild rank, and report every leader
rather than one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
server/db/schema.sql | 32 ++++-
server/model/shardState/shardState.db.js | 21 ++-
server/model/shardState/shardState.model.js | 8 ++
server/model/teamProvider/teamProvider.db.js | 26 +++-
.../model/teamProvider/teamProvider.model.js | 102 +++++++++++---
server/test/teamProvider.test.js | 125 +++++++++++++++---
6 files changed, 273 insertions(+), 41 deletions(-)
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 7d56898..4ce751b 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -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');
\ No newline at end of file
+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`);
diff --git a/server/model/shardState/shardState.db.js b/server/model/shardState/shardState.db.js
index 3c0233c..4dbc569 100644
--- a/server/model/shardState/shardState.db.js
+++ b/server/model/shardState/shardState.db.js
@@ -172,19 +172,32 @@ const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
-const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, t'
+// `rank` is backticked wherever it is written, like `int` on shard_online: it is a
+// reserved word in MySQL 8 and merely a keyword in MariaDB, so it parses bare here
+// and must not be relied on to.
+const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, `rank`, rank_cliloc, rank_name, t'
// Upsert rather than plain insert: a roster frame can be redelivered (the /history
// backfill replays stored frames on every reconnect), and a redelivery must be a
// no-op rather than a duplicate-key error.
+//
+// The rank columns are assigned unconditionally, NULL included. A member whose rank
+// the shard withheld — a staff account, whose GuildRank getter reports Leader
+// regardless of the truth — must go back to "not known" rather than keeping a rank
+// from before they were promoted.
const upsertGuildMembers = (rows) => {
if (!rows.length) return Promise.resolve()
- const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
- const params = rows.flatMap((r) => [r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player, r.t])
+ const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')
+ const params = rows.flatMap((r) => [
+ r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player,
+ r.rank, r.rank_cliloc, r.rank_name, r.t,
+ ])
return query(
`INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
- web_id = VALUES(web_id), is_player = VALUES(is_player), t = VALUES(t)`,
+ web_id = VALUES(web_id), is_player = VALUES(is_player),
+ \`rank\` = VALUES(\`rank\`), rank_cliloc = VALUES(rank_cliloc),
+ rank_name = VALUES(rank_name), t = VALUES(t)`,
params,
)
}
diff --git a/server/model/shardState/shardState.model.js b/server/model/shardState/shardState.model.js
index afde59e..7c25254 100644
--- a/server/model/shardState/shardState.model.js
+++ b/server/model/shardState/shardState.model.js
@@ -386,6 +386,14 @@ async function upsertGuildRoster(ev) {
acct: m.acct ?? null,
web_id: Number.isFinite(m.webId) ? m.webId : null,
is_player: m.player ? 1 : 0,
+ // Guild rank (Protocol 4). ABSENT is a real state and is stored as NULL: the
+ // shard withholds the rank for a staff account, because ServUO's GuildRank
+ // getter reports Leader for anyone at GameMaster or above whatever their
+ // actual rank. Defaulting a missing rank to 0 here would turn "we were not
+ // told" into "rank 0", which is a demotion invented by this line.
+ rank: Number.isInteger(m.rank) ? m.rank : null,
+ rank_cliloc: Number.isInteger(m.rankCliloc) ? m.rankCliloc : null,
+ rank_name: typeof m.rankName === 'string' && m.rankName ? m.rankName.slice(0, 64) : null,
t: Number.isFinite(ev.t) ? ev.t : null,
}))
diff --git a/server/model/teamProvider/teamProvider.db.js b/server/model/teamProvider/teamProvider.db.js
index 3608c26..88980ff 100644
--- a/server/model/teamProvider/teamProvider.db.js
+++ b/server/model/teamProvider/teamProvider.db.js
@@ -10,6 +10,9 @@
// though `ctx` does not exist yet when this file is first required.
const { query } = require('../../core')
+/** ServUO's `RankDefinition.Ranks[4]` is Leader, and 4 is the top of the ladder. */
+const LEADER_RANK = 4
+
/**
* The guild board — one row per guild the shard has told us about.
*
@@ -53,8 +56,8 @@ const findGuild = (id) =>
*/
const listGuildMembers = (guildId) =>
query(
- `SELECT m.serial, m.name, m.acct, m.web_id, m.is_player,
- l.user_id AS linked_user_id,
+ "SELECT m.serial, m.name, m.acct, m.web_id, m.is_player, m.`rank`, m.rank_cliloc, m.rank_name, " +
+ ` l.user_id AS linked_user_id,
(o.serial IS NOT NULL) AS is_online
FROM shard_guild_members m
LEFT JOIN shard_account_links l ON l.account = m.acct
@@ -64,4 +67,21 @@ const listGuildMembers = (guildId) =>
[guildId],
)
-module.exports = { listGuilds, findGuild, listGuildMembers }
+/**
+ * Every member at leader rank — rank 4, the top of ServUO's `RankDefinition.Ranks`.
+ *
+ * A set, not a single row, and that is the whole reason Protocol 4 grew a per-member
+ * rank: the guild board carries one `leader_serial`, so before this the website could
+ * only ever be told about one leader, while a UO guild routinely has several.
+ *
+ * A NULL rank is excluded by the comparison, which is correct — the shard withholds
+ * the rank for a staff account rather than publishing the Leader its getter falsely
+ * reports, and "not known" must not be read as "leads this guild".
+ */
+const listGuildLeaders = (guildId) =>
+ query(
+ 'SELECT serial FROM shard_guild_members WHERE guild_id = ? AND `rank` >= ? ORDER BY name ASC',
+ [guildId, LEADER_RANK],
+ )
+
+module.exports = { listGuilds, findGuild, listGuildMembers, listGuildLeaders, LEADER_RANK }
diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js
index a18888f..41f5d2b 100644
--- a/server/model/teamProvider/teamProvider.model.js
+++ b/server/model/teamProvider/teamProvider.model.js
@@ -24,9 +24,27 @@ const core = require('../../core')
const db = require('./teamProvider.db')
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../../utils/uoLinkSocket')
+const clilocs = require('../shardClilocs/shardClilocs.model')
const log = core.logger('teams')
+/**
+ * ServUO's five stock rank names, by the cliloc id the game names them with.
+ *
+ * A fallback, not the source of truth: the operator's own cliloc table is consulted
+ * first, and a shard with custom rank definitions sends a literal string that beats
+ * both. This 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 say
+ * "Warlord" rather than nothing.
+ */
+const STANDARD_RANK_NAMES = {
+ 1062959: 'Leader',
+ 1062960: 'Warlord',
+ 1062961: 'Emissary',
+ 1062962: 'Member',
+ 1062963: 'Ronin',
+}
+
/** A refusal, in the shape core reads (§2.3). */
const refuse = (reason) => ({ ok: false, reason })
@@ -116,17 +134,19 @@ async function getTeamMembers(externalId) {
return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
}
- const leaderSerial = guild.leader_serial || null
+ const labels = await rankLabels(rows)
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.serial,
displayName: row.name || null,
- // Not on the wire. The roster member is the standard actor object, which
- // carries no guild rank — see the note at the bottom of this file.
- rankLabel: null,
- leader: Boolean(leaderSerial && row.serial === leaderSerial),
+ rankLabel: labels.get(row.serial) || null,
+ // Rank 4 is Leader, and several members can hold it. A NULL rank is not a
+ // leader: the shard withholds the rank for a staff account rather than
+ // publishing the Leader its getter falsely reports, and "not known" must
+ // never be read as "leads this guild".
+ leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK,
online: Boolean(row.is_online),
userId: resolveUserId(row),
})),
@@ -138,17 +158,17 @@ async function getTeamMembers(externalId) {
}
/**
- * `getTeamLeaders(externalId)` — who leads the guild.
+ * `getTeamLeaders(externalId)` — everyone at leader rank.
*
- * **One leader, because that is all the wire carries.** TEAMS.md §2.5 expects
- * multiple leaders to be the normal case, from `PlayerMobile.GuildRank.Rank >= 4`,
- * and core supports them — but Protocol 4's roster member is the standard actor
- * object with no rank field, so the only leadership this module can see is the
- * board's single `leader_serial` from `guild.update`. Reporting a guessed second
- * leader would be worse than reporting one honestly.
+ * **All of them, which is why Protocol 4 grew a per-member rank.** The guild board
+ * carries one `leader_serial`, so before the rank amendment this could only ever
+ * name a single member, while a UO guild routinely has several at rank 4 and
+ * TEAMS.md §2.5 treats multiple leaders as the normal case.
*
- * Raising this to the full set is a protocol change (rank on the actor object),
- * not something this file can fix.
+ * The board's own `leader_serial` is folded in as a floor. It is the guild's
+ * founder-leader and it comes from a different frame (`guild.update`), so on a
+ * shard whose roster has not been re-emitted since the amendment it is the only
+ * leadership signal there is — and it should never be *lost* by moving to ranks.
*/
async function getTeamLeaders(externalId) {
const ready = await boardIsCurrent()
@@ -157,13 +177,65 @@ async function getTeamLeaders(externalId) {
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
- return { ok: true, leaders: guild.leader_serial ? [guild.leader_serial] : [] }
+
+ const rows = await db.listGuildLeaders(externalId)
+ const leaders = rows.map((r) => r.serial)
+
+ if (guild.leader_serial && !leaders.includes(guild.leader_serial)) {
+ leaders.push(guild.leader_serial)
+ }
+ return { ok: true, leaders }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, message: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
+/**
+ * Resolve each member's rank to a display label, keyed by serial.
+ *
+ * The shard sends the rank's NAME as the game states it — a cliloc id for the five
+ * standard ranks, or a literal string for a custom rank definition — and never a
+ * resolved label, because ServUO ships no text for those clilocs. This module does
+ * have a cliloc table, which is why the resolution belongs here.
+ *
+ * Three sources, in order: a custom string wins, 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 extraction, and a shard that has not
+ * should still read "Warlord" rather than nothing.
+ *
+ * Never throws: a rank label is decoration on a roster, and a lookup failure must
+ * not turn a good roster into a refusal.
+ */
+async function rankLabels(rows) {
+ const out = new Map()
+ const wanted = []
+
+ for (const row of rows) {
+ if (row.rank_name) {
+ out.set(row.serial, row.rank_name)
+ } else if (Number.isInteger(row.rank_cliloc)) {
+ wanted.push(row.rank_cliloc)
+ }
+ }
+
+ let resolved = new Map()
+ if (wanted.length) {
+ try {
+ resolved = await clilocs.resolveMany(wanted)
+ } catch (err) {
+ log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message })
+ }
+ }
+
+ for (const row of rows) {
+ if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue
+ const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null
+ if (label) out.set(row.serial, label)
+ }
+ return out
+}
+
/**
* The site account behind a character, or null.
*
diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js
index 4780b15..3305fb3 100644
--- a/server/test/teamProvider.test.js
+++ b/server/test/teamProvider.test.js
@@ -26,6 +26,7 @@ core.init({
const db = require('../model/teamProvider/teamProvider.db')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../utils/uoLinkSocket')
+const clilocs = require('../model/shardClilocs/shardClilocs.model')
const provider = require('../model/teamProvider/teamProvider.model')
const saved = []
@@ -39,6 +40,10 @@ function patch(mod, name, fn) {
function healthy() {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
+ // An operator who has never run the client extraction — the default. The standard
+ // rank names must still resolve from the fallback table.
+ patch(clilocs, 'resolveMany', async () => new Map())
+ patch(db, 'listGuildLeaders', async () => [])
}
const guild = (extra = {}) => ({
@@ -48,6 +53,7 @@ const guild = (extra = {}) => ({
const member = (extra = {}) => ({
serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
+ rank: 1, rank_cliloc: 1062962, rank_name: null,
linked_user_id: null, is_online: 0, ...extra,
})
@@ -167,8 +173,8 @@ test('the external id is a string, so core never compares a number to one', asyn
test('a roster maps to the member shape core expects', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
- member({ serial: '0x1', name: 'Aldric', is_online: 1 }),
- member({ serial: '0x2', name: 'Bree', acct: null, is_online: 0 }),
+ member({ serial: '0x1', name: 'Aldric', rank: 4, rank_cliloc: 1062959, is_online: 1 }),
+ member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, is_online: 0 }),
])
const { members } = await provider.getTeamMembers('1')
@@ -176,21 +182,113 @@ test('a roster maps to the member shape core expects', async () => {
assert.equal(members[0].memberKey, '0x1')
assert.equal(members[0].displayName, 'Aldric')
assert.equal(members[0].online, true)
- assert.equal(members[0].leader, true, 'matches the board’s leader_serial')
+ assert.equal(members[0].leader, true, 'rank 4 is Leader')
assert.equal(members[1].leader, false)
assert.equal(members[1].online, false)
})
-test('rankLabel is null, honestly — the wire carries no guild rank', async () => {
- // The roster member is the standard actor object (serial, name, player, acct?,
- // webId?). Inventing a rank from the leader flag would be core displaying a
- // label this module made up.
+// ── Rank (the Protocol 4 amendment) ────────────────────────────────────────
+
+test('several members can be leaders at once', async () => {
+ // The whole reason the wire grew a per-member rank: the board carries one
+ // leader_serial, so before this only a single leader could ever be reported.
patch(db, 'findGuild', async () => [guild()])
- patch(db, 'listGuildMembers', async () => [member()])
+ patch(db, 'listGuildMembers', async () => [
+ member({ serial: '0x1', rank: 4 }),
+ member({ serial: '0x2', rank: 4 }),
+ member({ serial: '0x3', rank: 3 }),
+ ])
+
const { members } = await provider.getTeamMembers('1')
+ assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2'])
+})
+
+test('getTeamLeaders returns everyone at rank 4, not just the board’s one', async () => {
+ patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
+ patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
+ assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2'])
+})
+
+test('the board’s leader is kept even when no roster row has rank yet', async () => {
+ // A shard whose roster has not been re-emitted since the amendment has no ranks
+ // stored. The founder-leader comes from a different frame and must not be lost
+ // by moving to ranks.
+ patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })])
+ patch(db, 'listGuildLeaders', async () => [])
+ assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9'])
+})
+
+test('the board’s leader is not duplicated when they also hold rank 4', async () => {
+ patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
+ patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
+ const { leaders } = await provider.getTeamLeaders('1')
+ assert.equal(new Set(leaders).size, leaders.length)
+})
+
+test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => {
+ // The shard withholds the rank for a staff account, because ServUO's GuildRank
+ // getter reports Leader for anyone at GameMaster or above whatever their real
+ // rank. Reading the absence as leadership would republish exactly that lie.
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })])
+
+ const { members } = await provider.getTeamMembers('1')
+ assert.equal(members[0].leader, false)
assert.equal(members[0].rankLabel, null)
})
+test('a standard rank resolves to its name without a cliloc table', async () => {
+ // The operator may never have run the client extraction, and a roster should
+ // still read "Warlord" rather than nothing.
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [
+ member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }),
+ member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }),
+ member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }),
+ ])
+
+ const { members } = await provider.getTeamMembers('1')
+ assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin'])
+})
+
+test('the operator’s cliloc table wins over the built-in names', async () => {
+ // A localised or edited client should name the ranks, not this module's English
+ // fallback.
+ patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']]))
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
+
+ assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr')
+})
+
+test('a custom rank’s literal name beats both', async () => {
+ // A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc,
+ // and its own naming has to survive.
+ patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']]))
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [
+ member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }),
+ ])
+
+ assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain')
+})
+
+test('a failing cliloc lookup falls back rather than failing the roster', async () => {
+ patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') })
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
+
+ const answer = await provider.getTeamMembers('1')
+ assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster')
+ assert.equal(answer.members[0].rankLabel, 'Warlord')
+})
+
+test('an unknown cliloc leaves the label null rather than inventing one', async () => {
+ patch(db, 'findGuild', async () => [guild()])
+ patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })])
+ assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, null)
+})
+
test('a member with no account at all is fine and unlinked', async () => {
// §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
// have no Account, and the local test world contains such mobiles.
@@ -221,16 +319,9 @@ test('web_id arrives as a string from the wire and is coerced', async () => {
assert.equal(typeof members[0].userId, 'number')
})
-test('leadership is the board’s single leader — one, and honestly one', async () => {
- // TEAMS.md §2.5 expects multiple leaders (GuildRank.Rank >= 4) and core
- // supports them, but Protocol 4 does not put rank on the wire. Raising this to
- // the full set is a protocol change, not something this file can fix.
- patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
- assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1'])
-})
-
-test('a guild with no leader on the board reports none rather than guessing', async () => {
+test('a guild with no leader anywhere reports none rather than guessing', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
+ patch(db, 'listGuildLeaders', async () => [])
const answer = await provider.getTeamLeaders('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.leaders, [])
--
2.49.1
From d4aa5ade1282b45f881ec548b147c124401dfba5 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 20:16:22 -0500
Subject: [PATCH 05/13] feat(teams): project rosters by audience rung, and add
to the Team page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
client/src/components/TeamOverviewStrip.jsx | 55 +++++++++++++++
client/src/entry.jsx | 13 ++++
client/test/registration.test.js | 8 ++-
.../model/teamProvider/teamProvider.model.js | 68 +++++++++++++++++-
server/test/teamProvider.test.js | 69 +++++++++++++++++++
5 files changed, 210 insertions(+), 3 deletions(-)
create mode 100644 client/src/components/TeamOverviewStrip.jsx
diff --git a/client/src/components/TeamOverviewStrip.jsx b/client/src/components/TeamOverviewStrip.jsx
new file mode 100644
index 0000000..d5116a2
--- /dev/null
+++ b/client/src/components/TeamOverviewStrip.jsx
@@ -0,0 +1,55 @@
+import { useMemo } from 'react'
+import { useShardFeed } from '../lib/useShardFeed.js'
+
+// What this module contributes to a core Team page (`team.overview`,
+// TEAMS.md §3.3, §3.4).
+//
+// **Core already renders an online count, and this does not replace it.** Core's
+// number is written by the Team sync from the roster the module answered, so it
+// is durable and refreshed at the reconcile interval — coarse by construction.
+// This one is live: the same `presence.online` feed the site header's widget
+// already consumes, which this module holds and core does not. Core's is the
+// floor; this is the current reading, and it says which it is rather than
+// silently disagreeing with the number three lines above it.
+//
+// It is emphatically NOT a per-Team presence feed. The shard publishes a global
+// online aggregate and no per-guild breakdown exists on the wire, so claiming one
+// here would be inventing a number. What it can honestly say is how many players
+// are on the shard right now, next to a roster whose own online marks are as old
+// as the last sync — which is the context a reader of that roster is missing.
+//
+// Renders nothing at all until the feed produces something. An empty slot is the
+// correct output when there is nothing true to add (§3.7): core's page is
+// complete without it, and a panel reading "unavailable" would be this module
+// making core's page worse than it is with no module installed.
+
+const PRESENCE_KINDS = new Set(['presence.online'])
+
+export default function TeamOverviewStrip() {
+ const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 2 })
+ const snapshot = events[0]
+
+ const total = useMemo(() => {
+ const n = Number(snapshot?.count)
+ return Number.isFinite(n) ? n : null
+ }, [snapshot])
+
+ // No feed yet, a disabled integration, or a shard that is down. All three are
+ // "nothing to add", and none of them is worth a box saying so.
+ if (total == null) return null
+
+ return (
+
+ {total === 0
+ ? 'Nobody is on the shard right now.'
+ : `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`}
+ {' '}
+
+ The per-member marks above are as recent as the last roster sync.
+
+
+ )
+}
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index 3b78cf0..bb60e52 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -52,6 +52,7 @@ import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import ShardStatusLink from './components/ShardStatusLink.jsx'
import UserShardSections from './routes/admin/UserShardSections.jsx'
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
+import TeamOverviewStrip from './components/TeamOverviewStrip.jsx'
const ID = 'uo'
@@ -180,6 +181,18 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
+// The fourth, and the first that was never core's: `team.overview` is new in
+// 1.6.0 and core renders the whole Team page without it (TEAMS.md §3.4). This
+// adds a live reading beside core's stored one, and renders nothing when it has
+// nothing true to say.
+//
+// `team.member.row` is declared by core and deliberately LEFT UNFILLED. Its
+// useful contents would be a link to the character behind a roster 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 (§3.2), so this module would be
+// guessing from a display name. Filling it with a guess is worse than an empty
+// cell.
+registry.registerExtension(ID, 'team.overview', TeamOverviewStrip)
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a
diff --git a/client/test/registration.test.js b/client/test/registration.test.js
index 24e536f..ba9de03 100644
--- a/client/test/registration.test.js
+++ b/client/test/registration.test.js
@@ -166,11 +166,15 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
-it('fills the three extension slots, each with a component', () => {
+it('fills the four extension slots, each with a component', () => {
const { extensions } = registered
+ // `team.member.row` is core-declared and deliberately absent: the props core
+ // can supply do not identify a character, because the member key and the site
+ // account id are withheld from every public roster (TEAMS.md §3.2). An empty
+ // cell beats a guess.
assert.deepEqual(
[...extensions.keys()].sort(),
- ['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
+ ['admin.users.detail', 'player.invite.accepted', 'site.footer.status', 'team.overview'],
)
for (const [slot, { id, Component }] of extensions) {
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js
index 41f5d2b..8b5b76b 100644
--- a/server/model/teamProvider/teamProvider.model.js
+++ b/server/model/teamProvider/teamProvider.model.js
@@ -25,6 +25,7 @@ const db = require('./teamProvider.db')
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../../utils/uoLinkSocket')
const clilocs = require('../shardClilocs/shardClilocs.model')
+const visibility = require('../../utils/shardVisibility')
const log = core.logger('teams')
@@ -251,4 +252,69 @@ function resolveUserId(row) {
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
}
-module.exports = { getTeams, getTeamMembers, getTeamLeaders, boardIsCurrent }
+/**
+ * Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0).
+ *
+ * The optional fourth provider method, and the only one core calls on a REQUEST
+ * path rather than from the reconciler. Core holds the roster and 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 (`utils/shardVisibility`)
+ * and core does not know what a rung is.
+ *
+ * **The answer is all-or-nothing, and that is correct rather than a shortcut.**
+ * A rung is a property of the FEATURE, not of a member: `guilds` is either
+ * visible to this viewer or it is not, and there is no configuration in which
+ * some members of a guild are public and others are not. Returning every key or
+ * none is the honest translation of the model this module actually has.
+ *
+ * **A refusal here costs visibility, not staleness.** Core fails closed on this
+ * one call — an unanswered visibility question serves an empty roster rather than
+ * an unprojected one — so every path below that cannot reach a confident answer
+ * refuses deliberately, and the catch does too. That is the opposite of the rule
+ * governing the other three methods, and it is the right way round: for a roster
+ * SYNC an unanswered call must change nothing, and for a roster READ it must
+ * publish nothing.
+ *
+ * Note what this does NOT do: strip fields. `acct` and `webId` are the leak this
+ * module's projection exists to prevent on the live feed, and neither is in
+ * core's roster shape at all — core withholds the member key and the site account
+ * id from every public roster whatever this returns. So there is nothing here to
+ * redact, only rows to withhold.
+ */
+async function projectRoster(externalId, members, viewer) {
+ try {
+ const config = await visibility.getConfig()
+ const feature = config.guilds
+ // An admin turned guilds off. Nobody sees a roster, including staff — the
+ // switch means "this shard does not publish guild data", not "publish it
+ // quietly".
+ if (!feature || !feature.enabled) return { ok: true, members: [] }
+
+ // `viewerLevel` reads a REQUEST; core hands over a described viewer instead,
+ // which is deliberate — it keeps the `users` row out of the contract.
+ //
+ // The no-viewer case is answered here rather than by handing `viewerLevel` an
+ // empty object: given a request with no `req.user` it falls through to
+ // `auth.getUserFromRequest`, which expects real cookies and headers and
+ // throws on a synthetic one. That throw would land in the catch below and
+ // become a REFUSAL, so every anonymous visitor would have been served an
+ // empty roster on a shard whose guilds are public. Anonymous is a known
+ // answer, not a failed lookup.
+ const level = viewer
+ ? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } })
+ : 'anonymous'
+ if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] }
+
+ return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) }
+ } catch (err) {
+ // Core reads this as "withhold the roster". Saying so is the whole point: the
+ // alternative — answering with every key because the config read failed —
+ // publishes a roster an operator may have gated to staff.
+ log.warn('projectRoster could not resolve visibility; withholding the roster', {
+ externalId, message: err.message,
+ })
+ return refuse(`visibility could not be resolved: ${err.message}`)
+ }
+}
+
+module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent }
diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js
index 3305fb3..c5c28fc 100644
--- a/server/test/teamProvider.test.js
+++ b/server/test/teamProvider.test.js
@@ -27,6 +27,7 @@ const db = require('../model/teamProvider/teamProvider.db')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../utils/uoLinkSocket')
const clilocs = require('../model/shardClilocs/shardClilocs.model')
+const visibility = require('../utils/shardVisibility')
const provider = require('../model/teamProvider/teamProvider.model')
const saved = []
@@ -335,3 +336,71 @@ test('an empty board is an authoritative empty list — the shard really has no
assert.equal(answer.ok, true)
assert.deepEqual(answer.teams, [])
})
+
+// ── projectRoster (TEAMS.md §3.3) ──────────────────────────────────────────
+//
+// The refusal semantics INVERT here and that is the point of these tests. For
+// the three methods above, a refusal means "change nothing" and an empty array
+// would be destructive. For this one, core fails CLOSED — a refusal withholds the
+// roster — 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.
+
+const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
+
+function guilds(feature) {
+ patch(visibility, 'getConfig', async () => ({ guilds: feature }))
+}
+
+test('a viewer at or above the audience sees every row', async () => {
+ guilds({ enabled: true, audience: 'anonymous' })
+ const answer = await provider.projectRoster('1', rows, null)
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, ['0x1', '0x2'])
+})
+
+test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => {
+ // `ok: true` with an empty list is the correct answer here: this module KNOWS
+ // the viewer may see nothing. Core renders an empty roster rather than an
+ // error, which is what a gated shard is supposed to look like.
+ guilds({ enabled: true, audience: 'staff' })
+ const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' })
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, [])
+})
+
+test('an admin clears every audience', async () => {
+ guilds({ enabled: true, audience: 'admin' })
+ const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
+ assert.deepEqual(answer.members, ['0x1', '0x2'])
+})
+
+test('a disabled guilds feature hides the roster from everyone, staff included', async () => {
+ // The switch means "this shard does not publish guild data", not "publish it
+ // quietly to staff".
+ guilds({ enabled: false, audience: 'anonymous' })
+ const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, [])
+})
+
+test('an unreadable visibility config REFUSES rather than publishing', async () => {
+ // The inversion, stated. Core reads this as "withhold", which is the only safe
+ // reading of "I could not work out who is allowed to look".
+ patch(visibility, 'getConfig', async () => { throw new Error('pool down') })
+ const answer = await provider.projectRoster('1', rows, null)
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /visibility could not be resolved/)
+})
+
+test('an absent viewer is anonymous, not an error', async () => {
+ guilds({ enabled: true, audience: 'logged_in' })
+ const answer = await provider.projectRoster('1', rows, null)
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in')
+})
+
+test('rows with no member key are dropped rather than answered as blanks', async () => {
+ guilds({ enabled: true, audience: 'anonymous' })
+ const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null)
+ assert.deepEqual(answer.members, ['0x1'])
+})
--
2.49.1
From dda0e32dd38b07741ad655bd98033d454445c9da Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 20:58:30 -0500
Subject: [PATCH 06/13] feat(guilds): a guild detail page, and the slot core
puts the feed in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
client/src/api.js | 1 +
client/src/components/TeamOverviewStrip.jsx | 55 ----------
client/src/core.js | 7 +-
client/src/entry.jsx | 24 ++---
client/src/routes/public/Guild.jsx | 110 ++++++++++++++++++++
client/src/routes/public/Guilds.jsx | 10 +-
client/test/registration.test.js | 38 +++++--
routes.manifest.json | 5 +
server/router/public/shard.controller.js | 26 +++++
server/router/public/shard.router.js | 11 ++
swagger-fragment.json | 50 +++++++++
11 files changed, 257 insertions(+), 80 deletions(-)
delete mode 100644 client/src/components/TeamOverviewStrip.jsx
create mode 100644 client/src/routes/public/Guild.jsx
diff --git a/client/src/api.js b/client/src/api.js
index 28aed0d..2746aab 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -39,6 +39,7 @@ export const shard = {
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
+ guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
diff --git a/client/src/components/TeamOverviewStrip.jsx b/client/src/components/TeamOverviewStrip.jsx
deleted file mode 100644
index d5116a2..0000000
--- a/client/src/components/TeamOverviewStrip.jsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useMemo } from 'react'
-import { useShardFeed } from '../lib/useShardFeed.js'
-
-// What this module contributes to a core Team page (`team.overview`,
-// TEAMS.md §3.3, §3.4).
-//
-// **Core already renders an online count, and this does not replace it.** Core's
-// number is written by the Team sync from the roster the module answered, so it
-// is durable and refreshed at the reconcile interval — coarse by construction.
-// This one is live: the same `presence.online` feed the site header's widget
-// already consumes, which this module holds and core does not. Core's is the
-// floor; this is the current reading, and it says which it is rather than
-// silently disagreeing with the number three lines above it.
-//
-// It is emphatically NOT a per-Team presence feed. The shard publishes a global
-// online aggregate and no per-guild breakdown exists on the wire, so claiming one
-// here would be inventing a number. What it can honestly say is how many players
-// are on the shard right now, next to a roster whose own online marks are as old
-// as the last sync — which is the context a reader of that roster is missing.
-//
-// Renders nothing at all until the feed produces something. An empty slot is the
-// correct output when there is nothing true to add (§3.7): core's page is
-// complete without it, and a panel reading "unavailable" would be this module
-// making core's page worse than it is with no module installed.
-
-const PRESENCE_KINDS = new Set(['presence.online'])
-
-export default function TeamOverviewStrip() {
- const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 2 })
- const snapshot = events[0]
-
- const total = useMemo(() => {
- const n = Number(snapshot?.count)
- return Number.isFinite(n) ? n : null
- }, [snapshot])
-
- // No feed yet, a disabled integration, or a shard that is down. All three are
- // "nothing to add", and none of them is worth a box saying so.
- if (total == null) return null
-
- return (
-
- {total === 0
- ? 'Nobody is on the shard right now.'
- : `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`}
- {' '}
-
- The per-member marks above are as recent as the last roster sync.
-
-
- )
-}
diff --git a/client/src/core.js b/client/src/core.js
index bce4793..f3da217 100644
--- a/client/src/core.js
+++ b/client/src/core.js
@@ -48,7 +48,7 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
)
}
-// The curated kit (§3.4). Seven members, closed: anything else this module needs
+// The curated kit (§3.4). Eight members, closed: anything else this module needs
// it bundles itself, which is why `components/` next door exists at all.
export const {
PublicLayout,
@@ -59,6 +59,11 @@ export const {
useAsync,
useAuth,
useSite,
+ // Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED
+ // direction — this module declares a place on its own page and CORE fills it.
+ // Shared rather than reimplemented so core's content failing inside our page is
+ // contained by core's own error boundary.
+ Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index bb60e52..1c10dc5 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -26,6 +26,7 @@ import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
+import Guild from './routes/public/Guild.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
@@ -52,7 +53,6 @@ import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import ShardStatusLink from './components/ShardStatusLink.jsx'
import UserShardSections from './routes/admin/UserShardSections.jsx'
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
-import TeamOverviewStrip from './components/TeamOverviewStrip.jsx'
const ID = 'uo'
@@ -82,6 +82,7 @@ registry.registerRoutes(ID, {
{ path: 'shard/activity', element: },
{ path: 'champs', element: },
{ path: 'guilds', element: },
+ { path: 'guilds/:id', element: },
{ path: 'governors', element: },
{ path: 'houses', element: },
{ path: 'rules', element: },
@@ -181,18 +182,17 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
-// The fourth, and the first that was never core's: `team.overview` is new in
-// 1.6.0 and core renders the whole Team page without it (TEAMS.md §3.4). This
-// adds a live reading beside core's stored one, and renders nothing when it has
-// nothing true to say.
+// ── The inverted slot: this module DECLARES, core fills ────────────────────
//
-// `team.member.row` is declared by core and deliberately LEFT UNFILLED. Its
-// useful contents would be a link to the character behind a roster 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 (§3.2), so this module would be
-// guessing from a display name. Filling it with a guess is worse than an empty
-// cell.
-registry.registerExtension(ID, 'team.overview', TeamOverviewStrip)
+// The other three above are core's slots that this module fills. This one is the
+// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
+// populates, but core does not own the word "guild" and publishes no Team page of
+// its own — so the page is ours and core contributes the activity feed to it.
+//
+// Declared under this module's own namespace, which core enforces. Core's fill is
+// applied after every module chunk has evaluated, so declaring it here is early
+// enough; on a core that knows nothing of Teams it simply stays empty.
+registry.declareModuleSlot(ID, 'uo.guild.detail')
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a
diff --git a/client/src/routes/public/Guild.jsx b/client/src/routes/public/Guild.jsx
new file mode 100644
index 0000000..91ce62c
--- /dev/null
+++ b/client/src/routes/public/Guild.jsx
@@ -0,0 +1,110 @@
+import { useParams, Link } from 'react-router-dom'
+import api from '../../api.js'
+import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
+
+// One guild: its roster, and the place core puts the Team activity feed.
+//
+// **This page is the reason the extension-slot direction inverts**
+// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this
+// module is what populates them — but core does not own the word "guild", so it
+// publishes no Team page of its own. The page is this module's; the activity feed
+// on it is core's, because only core can resolve whether the viewer is inside the
+// Team, and the public/members split on that feed is a security boundary.
+//
+// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a
+// core that does not know about Teams the slot is simply never filled and this
+// page renders its roster alone, which is the same tolerance every other slot has.
+//
+// The roster comes from this module's OWN board — the same data it answers core's
+// Team provider from — rather than from core's Team API. That is deliberate: the
+// board is the authoritative copy here, and reading core's projection of our own
+// answer back would be a round trip through a staler copy of our own data.
+
+function rankOf(m) {
+ // Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for
+ // staff, because ServUO reports GameMaster-and-above as Leader whatever their
+ // real rank — emitting that verbatim would publish every staff member in a
+ // guild as one of its leaders (docs/link/v4.md).
+ if (m.rankName) return m.rankName
+ return null
+}
+
+function MemberRow({ m }) {
+ const rank = rankOf(m)
+ const linked = m.webId != null || m.acct != null
+ return (
+
+
+
+ {/* Keyed by serial: two characters can share a display name,
+ which this shard's own world actually contains. */}
+ {roster.map((m) => )}
+
+
+
+ )}
+
+ {roster.length === 0 && (
+
No roster has been received for this guild yet.
+ )}
+
+ {/* Core's Team activity feed lands here. Nothing renders on a core
+ that does not fill it, or when there is nothing to show. The guild
+ is named in OUR terms — core maps its own Team from these two. */}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/client/src/routes/public/Guilds.jsx b/client/src/routes/public/Guilds.jsx
index b907c5d..be9ec0e 100644
--- a/client/src/routes/public/Guilds.jsx
+++ b/client/src/routes/public/Guilds.jsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
@@ -15,9 +16,12 @@ function Leader({ leader }) {
function GuildRow({ g }) {
return (
-
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
-
+
)
}
diff --git a/client/test/registration.test.js b/client/test/registration.test.js
index ba9de03..f3a124c 100644
--- a/client/test/registration.test.js
+++ b/client/test/registration.test.js
@@ -44,6 +44,7 @@ function fakeRg() {
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
+ const declaredSlots = new Set()
return {
version: '1.3.0',
react,
@@ -54,7 +55,7 @@ function fakeRg() {
// object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
- ['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
+ ['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
@@ -72,10 +73,19 @@ function fakeRg() {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
+ // The INVERTED direction (core API 1.6.0): this module declares a place on
+ // its OWN page and core fills it. Core enforces the namespace, so the fake
+ // does too — a chunk that declared an unnamespaced slot would pass here and
+ // throw in a browser.
+ declareModuleSlot(id, name) {
+ if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
+ if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
+ declaredSlots.add(name)
+ },
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
- _read: () => ({ routes, nav, providers, extensions }),
+ _read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
@@ -98,7 +108,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
it('registers routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
- assert.equal(routes.public.length, 12)
+ assert.equal(routes.public.length, 13)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
@@ -166,15 +176,11 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
-it('fills the four extension slots, each with a component', () => {
+it('fills the three CORE extension slots, each with a component', () => {
const { extensions } = registered
- // `team.member.row` is core-declared and deliberately absent: the props core
- // can supply do not identify a character, because the member key and the site
- // account id are withheld from every public roster (TEAMS.md §3.2). An empty
- // cell beats a guess.
assert.deepEqual(
[...extensions.keys()].sort(),
- ['admin.users.detail', 'player.invite.accepted', 'site.footer.status', 'team.overview'],
+ ['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
)
for (const [slot, { id, Component }] of extensions) {
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
@@ -202,3 +208,17 @@ it('registers under exactly one module id, matching the manifest', () => {
])
assert.deepEqual([...owners], [manifest.id])
})
+
+it('declares its own guild-detail slot, for core to fill', () => {
+ // The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
+ // core page: core owns the activity feed and this module owns the word "guild",
+ // so this module declares the place and core puts the feed in it.
+ assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail'])
+})
+
+it('the declared slot is rendered by the page that owns it', () => {
+ // A slot nothing renders is a slot core fills into the void. Asserted against
+ // the source rather than the chunk, since the chunk is minified.
+ const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
+ assert.match(page, /name="uo\.guild\.detail"/)
+})
diff --git a/routes.manifest.json b/routes.manifest.json
index a588abd..1b8d47d 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -201,6 +201,11 @@
"path": "/api/v1/public/shard/guilds",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/shard/guilds/:id",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/public/shard/houses",
diff --git a/server/router/public/shard.controller.js b/server/router/public/shard.controller.js
index cfa1357..10604e4 100644
--- a/server/router/public/shard.controller.js
+++ b/server/router/public/shard.controller.js
@@ -177,6 +177,31 @@ async function getGuilds(req, res) {
}
}
+// GET /public/shard/guilds/:id — one guild and its roster.
+//
+// The board endpoint above returns every guild WITHOUT its roster; this is the
+// detail view, and it is the page that hosts core's Team activity feed through
+// the `uo.guild.detail` slot (docs/website/TEAMS.md Part 3).
+//
+// Projected through the same `guilds` feature as the board, so an operator who
+// gates guilds to staff gates this too, and `acct`/`webId` on the roster rows
+// never survive below admin — those are LOCKED fields, and a roster is where they
+// actually appear in bulk.
+async function getGuild(req, res) {
+ try {
+ const guilds = await shardState.listGuilds()
+ const guild = guilds.find((g) => String(g.id) === String(req.params.id))
+ // 404 rather than an empty object: a guild that disbanded is gone, and the
+ // page needs to say so rather than render an empty shell.
+ if (!guild) return res.status(404).json({ message: 'Not Found' })
+ const members = await shardState.listGuildMembers(guild.id)
+ return res.json(await visibility.project('guilds', { ...guild, roster: members }, req))
+ } catch (err) {
+ log.error('shard.getGuild', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream. Projected
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
@@ -421,6 +446,7 @@ module.exports = {
getIdoc,
getChamps,
getGuilds,
+ getGuild,
getGovernors,
getGovernorHistory,
getPresence,
diff --git a/server/router/public/shard.router.js b/server/router/public/shard.router.js
index 4997add..bcd744e 100644
--- a/server/router/public/shard.router.js
+++ b/server/router/public/shard.router.js
@@ -100,6 +100,17 @@ shardRouter.get(
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGuilds,
)
+shardRouter.get(
+ '/guilds/:id',
+ requireFeature('guilds'),
+ // #swagger.tags = ['Public · Shard']
+ // #swagger.summary = 'One guild and its roster'
+ // #swagger.description = 'The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.'
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The guild id.' }
+ /* #swagger.responses[200] = { description: 'The guild, with its roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[404] = { description: 'No such guild', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ shard.getGuild,
+)
shardRouter.get(
'/governors',
requireFeature('governors'),
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 65e2897..bda4e1d 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -3131,6 +3131,56 @@
}
}
},
+ "/api/v1/public/shard/guilds/{id}": {
+ "get": {
+ "tags": [
+ "Public · Shard"
+ ],
+ "summary": "One guild and its roster",
+ "description": "The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The guild id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The guild, with its roster",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "No such guild",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
"/api/v1/public/shard/houses": {
"get": {
"tags": [
--
2.49.1
From 9d0a1970089e4e4afaa860a96f4a97a91e54f977 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 18 Aug 2026 07:24:37 -0500
Subject: [PATCH 07/13] feat(guilds): declare a second place on the guild page,
for core's forum
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
client/src/entry.jsx | 8 ++++++++
client/src/routes/public/Guild.jsx | 5 +++++
client/test/registration.test.js | 18 ++++++++++++------
3 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index 1c10dc5..ef9177d 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -194,6 +194,14 @@ registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// enough; on a core that knows nothing of Teams it simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail')
+// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
+// declarations rather than one, because a slot holds one component and this module
+// wants to decide where each of core's two contributions sits on its own page —
+// the feed reads as part of the guild's story, the forum is a room you go into.
+// Neither knows the other exists, and a core that fills only one leaves the other
+// empty.
+registry.declareModuleSlot(ID, 'uo.guild.forum')
+
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a
// mismatch between the core that validated the manifest and the core that
diff --git a/client/src/routes/public/Guild.jsx b/client/src/routes/public/Guild.jsx
index 91ce62c..945cc21 100644
--- a/client/src/routes/public/Guild.jsx
+++ b/client/src/routes/public/Guild.jsx
@@ -102,6 +102,11 @@ export default function Guild() {
that does not fill it, or when there is nothing to show. The guild
is named in OUR terms — core maps its own Team from these two. */}
+
+ {/* And the Team forum, in its own place below the feed. Core resolves
+ who may read it — membership and manual grants are core's rules —
+ so this module renders the room and never its door policy. */}
+
>
)}
diff --git a/client/test/registration.test.js b/client/test/registration.test.js
index f3a124c..5378bea 100644
--- a/client/test/registration.test.js
+++ b/client/test/registration.test.js
@@ -209,16 +209,22 @@ it('registers under exactly one module id, matching the manifest', () => {
assert.deepEqual([...owners], [manifest.id])
})
-it('declares its own guild-detail slot, for core to fill', () => {
+it('declares its own guild slots, for core to fill', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
- // core page: core owns the activity feed and this module owns the word "guild",
- // so this module declares the place and core puts the feed in it.
- assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail'])
+ // core page: core owns the activity feed and the forum, this module owns the
+ // word "guild", so this module declares the places and core puts them in.
+ //
+ // TWO slots rather than one because a slot holds one component: stacking the
+ // feed and the forum into a single fill would take away this module's ability
+ // to place them separately on its own page.
+ assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail', 'uo.guild.forum'])
})
-it('the declared slot is rendered by the page that owns it', () => {
+it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
- assert.match(page, /name="uo\.guild\.detail"/)
+ for (const name of registered.declaredSlots) {
+ assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
+ }
})
--
2.49.1
From 46e3f5a1277fe355526b24d60e3be95299e6a1d9 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 18 Aug 2026 07:31:58 -0500
Subject: [PATCH 08/13] ci(core-ref): bump the pin past registerTeamProvider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`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
---
ci/core-ref.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/ci/core-ref.json b/ci/core-ref.json
index 9e78853..023530a 100644
--- a/ci/core-ref.json
+++ b/ci/core-ref.json
@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
- "ref": "87230c879aa6e9adde3507718aed6bc4e4d86009",
- "refName": "edge @ phase 3 slice 4 (website#140)"
+ "ref": "7ed2ac99838f4bd64e1df324fe4961673648b0e6",
+ "refName": "edge @ Teams phase 3 (website#152)"
}
--
2.49.1
From c57310c50556f6ecac3acd1864b99567f6f0bfa5 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 18 Aug 2026 14:35:36 -0500
Subject: [PATCH 09/13] feat(guilds): a third place on the guild page, and
where that page lives
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
client/src/entry.jsx | 7 +++++++
client/src/routes/public/Guild.jsx | 7 +++++++
client/test/registration.test.js | 14 ++++++++++----
server/model/teamProvider/teamProvider.model.js | 16 +++++++++++++++-
4 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index ef9177d..90209b3 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -202,6 +202,13 @@ registry.declareModuleSlot(ID, 'uo.guild.detail')
// empty.
registry.declareModuleSlot(ID, 'uo.guild.forum')
+// And a THIRD, at the top of the same page, for core's per-Team notification
+// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
+// muting a guild is an action ON this page, so it sits with the page's heading
+// rather than after its content. Core resolves whether this viewer is in the
+// Team at all — this module neither knows nor asks.
+registry.declareModuleSlot(ID, 'uo.guild.header')
+
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a
// mismatch between the core that validated the manifest and the core that
diff --git a/client/src/routes/public/Guild.jsx b/client/src/routes/public/Guild.jsx
index 945cc21..eea0df8 100644
--- a/client/src/routes/public/Guild.jsx
+++ b/client/src/routes/public/Guild.jsx
@@ -75,6 +75,13 @@ export default function Guild() {
{data.alliance && ` · ${data.alliance}`}
+ {/* A third place for core, up here rather than below the roster: core
+ puts this guild's notification control in it, and a control that
+ acts on the page belongs beside the page's title and not after its
+ content. Empty for a visitor with no membership, and on a core
+ that fills nothing. */}
+
+
{roster.length > 0 && (
diff --git a/client/test/registration.test.js b/client/test/registration.test.js
index 5378bea..9e41927 100644
--- a/client/test/registration.test.js
+++ b/client/test/registration.test.js
@@ -214,10 +214,16 @@ it('declares its own guild slots, for core to fill', () => {
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
//
- // TWO slots rather than one because a slot holds one component: stacking the
- // feed and the forum into a single fill would take away this module's ability
- // to place them separately on its own page.
- assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail', 'uo.guild.forum'])
+ // THREE slots rather than one because a slot holds one component: stacking the
+ // feed, the forum and the notification control into a single fill would take
+ // away this module's ability to place them separately on its own page — and it
+ // does place them separately, the control above the roster and the other two
+ // below it.
+ assert.deepEqual([...registered.declaredSlots], [
+ 'uo.guild.detail',
+ 'uo.guild.forum',
+ 'uo.guild.header',
+ ])
})
it('every declared slot is rendered by the page that owns it', () => {
diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js
index 8b5b76b..4196520 100644
--- a/server/model/teamProvider/teamProvider.model.js
+++ b/server/model/teamProvider/teamProvider.model.js
@@ -317,4 +317,18 @@ async function projectRoster(externalId, members, viewer) {
}
}
-module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent }
+// Where core should point a link at a guild (MODULE_API 1.6.0, TEAMS.md §6.4).
+//
+// **Core cannot work this out for itself, and it is not supposed to.** Teams are
+// a contract primitive with no core surface — this module owns the guild page,
+// because core does not own the word "guild" — so the one thing core needs back
+// is where the page it does not own actually lives. A notification email that
+// cannot link to the thread it is about is most of the way to useless.
+//
+// A relative path with `{externalId}` substituted, matching `Guild.jsx`'s route
+// (`/uo/guilds/:id`). Core does the substitution and nothing else with it; a
+// template naming its own host is refused at registration, which is why this is
+// data and not a callback.
+const pageUrlTemplate = '/uo/guilds/{externalId}'
+
+module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate }
--
2.49.1
From 2d1d91e3722b070fef92ff89cfe2cca145681235 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 18 Aug 2026 18:53:45 -0500
Subject: [PATCH 10/13] feat(guilds): /guild, the module's own chat command
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
server/commands/guild.command.js | 189 ++++++++++++++++++
server/index.js | 11 +
.../model/teamProvider/teamProvider.model.js | 7 +-
server/test/_fakes.js | 5 +
server/test/guildCommand.test.js | 139 +++++++++++++
5 files changed, 350 insertions(+), 1 deletion(-)
create mode 100644 server/commands/guild.command.js
create mode 100644 server/test/guildCommand.test.js
diff --git a/server/commands/guild.command.js b/server/commands/guild.command.js
new file mode 100644
index 0000000..2958a56
--- /dev/null
+++ b/server/commands/guild.command.js
@@ -0,0 +1,189 @@
+// ── `/guild` — the first chat command through the module contract ──────────
+//
+// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1).
+// The definition and this handler live here; the bot pulls the definition over
+// the app's internal API and runs nothing of ours. Nothing in this file knows
+// what Discord is — it is handed an `actor` and returns an envelope, and the
+// same handler would serve a second platform unchanged.
+//
+// **Why `/guild` and not `/team`.** Teams are core's primitive and "guild" is
+// this module's word for one; core does not own the word, so it does not publish
+// the noun in a channel either. That is the same correction that deleted core's
+// Team pages in phase 3, applied to the chat surface.
+//
+// **The audience rungs are enforced here, exactly as they are on the website.**
+// A shard whose `guilds` feature is gated to staff does not become public
+// because the question arrived over Discord — this handler resolves the caller's
+// rung through the same `shardVisibility` config the routes use. It is the one
+// piece of this file that is a security boundary rather than presentation.
+const core = require('../core')
+const db = require('../model/teamProvider/teamProvider.db')
+const provider = require('../model/teamProvider/teamProvider.model')
+const visibility = require('../utils/shardVisibility')
+
+const log = core.logger('guild-command')
+
+// How many guilds the no-argument form lists. A Discord embed takes 25 fields;
+// ten is a summary a person reads rather than a table they scroll past.
+const LIST_LIMIT = 10
+
+/**
+ * Where the caller sits on this module's ladder.
+ *
+ * The same resolution `projectRoster` does, and it is duplicated in shape rather
+ * than shared because the inputs differ: that one is handed a viewer core
+ * described, this one an actor. Both end at `viewerLevel`, and both answer
+ * `anonymous` DIRECTLY for a caller with no site account — handing `viewerLevel`
+ * a synthetic empty request makes it fall through to `auth.getUserFromRequest`,
+ * which expects real cookies and throws (the phase 3 bug).
+ */
+async function levelFor(actor) {
+ if (!actor || !actor.userId) return 'anonymous'
+ return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
+}
+
+// The nudge §9 answer 5 asks for, and only when it is TRUE. An unlinked caller
+// who was told nothing because the shard publishes nothing is not helped by
+// being invited to link; the prompt appears when linking is what would actually
+// change the answer.
+function linkPrompt(actor, audience) {
+ if (actor.isLinked) return null
+ if (audience === 'anonymous') return null
+ return 'Link your account on the site to see more — this shard shows guild information to linked players.'
+}
+
+const pageUrl = (externalId) =>
+ `${core.baseUrl}${provider.pageUrlTemplate.replace('{externalId}', externalId)}`
+
+// Match on abbreviation first, then an exact name, then a unique prefix. Players
+// type the abbreviation — it is what appears over a character's head — and a
+// wrong-guild answer is worse than "say which one".
+function findByName(rows, wanted) {
+ const needle = wanted.trim().toLowerCase()
+ const byAbbr = rows.filter((r) => (r.abbr || '').toLowerCase() === needle)
+ if (byAbbr.length === 1) return { guild: byAbbr[0] }
+ const exact = rows.filter((r) => r.name.toLowerCase() === needle)
+ if (exact.length === 1) return { guild: exact[0] }
+ const partial = rows.filter((r) => r.name.toLowerCase().includes(needle))
+ if (partial.length === 1) return { guild: partial[0] }
+ if (partial.length > 1) return { ambiguous: partial.slice(0, LIST_LIMIT) }
+ return {}
+}
+
+/** The counts for one guild, from the roster rather than the board's assertions. */
+async function summarise(guild) {
+ const members = await db.listGuildMembers(guild.id)
+ const leaders = members
+ .filter((m) => Number(m.rank) >= db.LEADER_RANK)
+ .map((m) => m.name)
+ // The board's founder-leader is folded in as a floor, the same way
+ // getTeamLeaders does it: it arrives on a different frame, and a shard whose
+ // roster predates the rank amendment has no other leadership signal.
+ if (guild.leader_name && !leaders.includes(guild.leader_name)) leaders.push(guild.leader_name)
+
+ return {
+ // `members`/`online` are the BOARD's counts, which is what the shard asserts;
+ // the roster is what it enumerated, and the two legitimately disagree for the
+ // moment between a membership change and the sweep that reports it. The
+ // assertion is the more current of the two, so it is what is shown.
+ members: guild.members,
+ online: guild.online,
+ linked: members.filter((m) => provider.resolveUserId(m) !== null).length,
+ leaders,
+ }
+}
+
+async function detail(guild, actor, audience) {
+ const counts = await summarise(guild)
+ const fields = [
+ { name: 'Members', value: String(counts.members ?? '—'), inline: true },
+ { name: 'Online', value: String(counts.online ?? 0), inline: true },
+ { name: 'Linked accounts', value: String(counts.linked), inline: true },
+ ]
+ if (counts.leaders.length) {
+ fields.push({ name: 'Leaders', value: counts.leaders.join(', ') })
+ }
+ return {
+ title: guild.abbr ? `${guild.name} [${guild.abbr}]` : guild.name,
+ text: guild.alliance ? `Alliance: ${guild.alliance}` : undefined,
+ fields,
+ url: pageUrl(guild.id),
+ notice: linkPrompt(actor, audience),
+ }
+}
+
+/**
+ * `/guild [name]` — one guild's summary, or the shard's largest guilds.
+ *
+ * Never throws for an ordinary miss: "no such guild" and "the shard is offline"
+ * are answers, and letting either become an exception would turn a routine
+ * question into "that command failed" with nothing an operator could act on.
+ */
+async function handler({ options, actor }) {
+ const config = await visibility.getConfig()
+ const feature = config.guilds
+
+ // An admin turned guilds off. The switch means "this shard does not publish
+ // guild data" — over any surface, to anyone, staff included.
+ if (!feature || !feature.enabled) {
+ return { text: 'This shard does not publish guild information.', ephemeral: true }
+ }
+
+ const level = await levelFor(actor)
+ if (!visibility.meets(level, feature.audience)) {
+ return {
+ text: 'Guild information on this shard is not shown to your account.',
+ ephemeral: true,
+ notice: linkPrompt(actor, feature.audience),
+ }
+ }
+
+ // The provider's own staleness guard, asked before any board read: an
+ // unreachable sidecar means the board is a snapshot of unknown age, and
+ // reporting it as current here would contradict what every other surface says.
+ const ready = await provider.boardIsCurrent()
+ if (!ready.ok) {
+ log.info('guild command answered offline', { reason: ready.reason })
+ return { text: 'The shard is not connected right now, so guild information may be out of date.', ephemeral: true }
+ }
+
+ const rows = await db.listGuilds()
+ if (!rows.length) return { text: 'No guilds are on the board yet.', ephemeral: true }
+
+ const wanted = options && typeof options.name === 'string' ? options.name : null
+ if (!wanted) {
+ const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT)
+ return {
+ title: `Guilds on ${core.baseUrl.replace(/^https?:\/\//, '')}`,
+ fields: top.map((g) => ({
+ name: g.abbr ? `${g.name} [${g.abbr}]` : g.name,
+ value: `${g.members || 0} members · ${g.online || 0} online`,
+ inline: true,
+ })),
+ notice: linkPrompt(actor, feature.audience),
+ }
+ }
+
+ const { guild, ambiguous } = findByName(rows, wanted)
+ if (ambiguous) {
+ return {
+ text: `Several guilds match “${wanted}”: ${ambiguous.map((g) => g.name).join(', ')}`,
+ ephemeral: true,
+ }
+ }
+ if (!guild) return { text: `No guild matches “${wanted}”.`, ephemeral: true }
+ return detail(guild, actor, feature.audience)
+}
+
+module.exports = {
+ name: 'guild',
+ description: 'Show a guild on this shard — members, who is online, and its leaders',
+ options: [
+ { name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
+ ],
+ // Everyone, deliberately. The gate that matters is the shard's own audience
+ // rung, resolved inside the handler — `access: 'linked'` would hide the command
+ // from exactly the unlinked members §9 answer 5 wants to invite to link.
+ access: 'everyone',
+ handler,
+}
diff --git a/server/index.js b/server/index.js
index 96c91fc..1421178 100644
--- a/server/index.js
+++ b/server/index.js
@@ -46,6 +46,7 @@ module.exports = function register(ctx, api) {
const shardStreams = require('./config/shardStreams')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
+ const guildCommand = require('./commands/guild.command')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -96,6 +97,16 @@ module.exports = function register(ctx, api) {
// the database, and registration must not.
api.registerTeamProvider(teamProvider)
+ // `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
+ // §7.1). The definition travels to the bot; the handler stays here and runs in
+ // the website process, because the bot container has no `modules` volume and
+ // cannot load a line of this module's code.
+ //
+ // Core registers NO commands of its own. "Guild" is this module's word — core
+ // does not own it on a page (phase 3) and does not publish it in a channel
+ // either.
+ api.registerSlashCommands([guildCommand])
+
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js
index 4196520..5e61592 100644
--- a/server/model/teamProvider/teamProvider.model.js
+++ b/server/model/teamProvider/teamProvider.model.js
@@ -331,4 +331,9 @@ async function projectRoster(externalId, members, viewer) {
// data and not a callback.
const pageUrlTemplate = '/uo/guilds/{externalId}'
-module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate }
+// `resolveUserId` is exported for the `/guild` chat command, which counts linked
+// members and must decide "linked" by the same rule the roster does — a second
+// copy of that two-source check is a copy that drifts.
+module.exports = {
+ getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate, resolveUserId,
+}
diff --git a/server/test/_fakes.js b/server/test/_fakes.js
index 8aaee4d..6397e16 100644
--- a/server/test/_fakes.js
+++ b/server/test/_fakes.js
@@ -96,6 +96,7 @@ function fakeApi() {
streams: null,
legs: [],
teamProvider: null,
+ slashCommands: [],
hooks: {},
}
const called = new Set()
@@ -112,6 +113,10 @@ function fakeApi() {
// deployment — a second registration is a collision there, so it has to be
// one here too, or this suite would pass a shape core rejects at load.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
+ // MODULE_API 1.6.0, live since phase 7. `once` for the same reason core
+ // takes it: a second call is a module changing its mind halfway through
+ // register(), which core rejects.
+ registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}
diff --git a/server/test/guildCommand.test.js b/server/test/guildCommand.test.js
new file mode 100644
index 0000000..af33659
--- /dev/null
+++ b/server/test/guildCommand.test.js
@@ -0,0 +1,139 @@
+// `/guild` — the chat command registered through `api.registerSlashCommands`
+// (TEAMS.md §7.1, MODULE_API 1.6.0).
+//
+// The properties worth pinning are all about the ANSWER being the same answer
+// the website gives, because that is the whole risk of a second surface: the
+// audience rungs are re-resolved here rather than assumed, the shard's own
+// offline guard is honoured, and the link prompt appears only when linking would
+// actually change what the caller is told.
+
+const { test, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const command = require('../commands/guild.command')
+const db = require('../model/teamProvider/teamProvider.db')
+const provider = require('../model/teamProvider/teamProvider.model')
+const visibility = require('../utils/shardVisibility')
+
+const originals = {
+ getConfig: visibility.getConfig,
+ viewerLevel: visibility.viewerLevel,
+ boardIsCurrent: provider.boardIsCurrent,
+ listGuilds: db.listGuilds,
+ listGuildMembers: db.listGuildMembers,
+}
+
+afterEach(() => {
+ visibility.getConfig = originals.getConfig
+ visibility.viewerLevel = originals.viewerLevel
+ provider.boardIsCurrent = originals.boardIsCurrent
+ db.listGuilds = originals.listGuilds
+ db.listGuildMembers = originals.listGuildMembers
+})
+
+const GUILDS = [
+ { id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' },
+ { id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null },
+]
+
+const MEMBERS = [
+ { serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null },
+ { serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 },
+ { serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null },
+]
+
+function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) {
+ visibility.getConfig = async () => ({ guilds: { enabled, audience } })
+ visibility.viewerLevel = async () => level
+ provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' })
+ db.listGuilds = async () => GUILDS
+ db.listGuildMembers = async () => MEMBERS
+}
+
+const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false }
+const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false }
+
+test('the definition stays inside the option schema §7.1.1 allows', () => {
+ assert.equal(command.name, 'guild')
+ assert.equal(command.access, 'everyone')
+ for (const option of command.options) {
+ assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type))
+ assert.ok(option.description.length <= 100)
+ }
+})
+
+test('the guilds feature being off withholds everything, staff included', async () => {
+ stub({ enabled: false, level: 'admin' })
+ const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } })
+ assert.match(res.text, /does not publish guild information/)
+ assert.equal(res.ephemeral, true)
+})
+
+// The reason this command is not a thin wrapper over a public route: a rung
+// below the feature's audience must be refused HERE, or a shard that gates
+// guilds to staff would publish them to a Discord channel.
+test('a caller below the feature audience is refused', async () => {
+ stub({ audience: 'staff', level: 'anonymous' })
+ const res = await command.handler({ options: {}, actor: anonymous })
+ assert.match(res.text, /not shown to your account/)
+ assert.equal(res.ephemeral, true)
+})
+
+test('an unlinked caller is invited to link — but only when linking would change the answer', async () => {
+ stub({ audience: 'player', level: 'anonymous' })
+ const gated = await command.handler({ options: {}, actor: anonymous })
+ assert.match(gated.notice, /Link your account/)
+
+ // Public guilds: there is nothing more to see, so there is nothing to prompt.
+ stub({ audience: 'anonymous', level: 'anonymous' })
+ const open = await command.handler({ options: {}, actor: anonymous })
+ assert.equal(open.notice, null)
+})
+
+test('a stale board answers offline rather than reporting what it still holds', async () => {
+ stub({ current: false })
+ const res = await command.handler({ options: {}, actor: anonymous })
+ assert.match(res.text, /not connected right now/)
+})
+
+test('no argument lists the largest guilds', async () => {
+ stub()
+ const res = await command.handler({ options: {}, actor: anonymous })
+ assert.equal(res.fields.length, 2)
+ assert.match(res.fields[0].name, /Knights of the Codex/)
+ assert.match(res.fields[0].value, /12 members · 3 online/)
+})
+
+test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => {
+ stub()
+ const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous })
+ assert.match(byAbbr.title, /Knights of the Codex/)
+
+ const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous })
+ assert.match(exact.title, /Hospitaller/)
+
+ // "knights" hits both, and answering with either would be worse than asking.
+ const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous })
+ assert.match(ambiguous.text, /Several guilds match/)
+ assert.equal(ambiguous.ephemeral, true)
+})
+
+test('a miss is an answer, not a failure', async () => {
+ stub()
+ const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous })
+ assert.match(res.text, /No guild matches/)
+})
+
+// `linked` counts BOTH sources the roster uses — the shard's asserted web id and
+// the link table — because that is what "linked" means everywhere else here.
+test('the detail carries the counts, the leaders and a link to the module page', async () => {
+ stub({ level: 'player' })
+ const res = await command.handler({ options: { name: 'KOC' }, actor: linked })
+ const field = (name) => res.fields.find((f) => f.name === name).value
+ assert.equal(field('Members'), '12')
+ assert.equal(field('Online'), '3')
+ assert.equal(field('Linked accounts'), '2')
+ assert.equal(field('Leaders'), 'Dain, Elowen')
+ assert.match(res.url, /\/uo\/guilds\/7$/)
+ assert.equal(res.notice, null)
+})
--
2.49.1
From 466842c6f27fcbcb1fcab8dce833ceb3f9b69ad1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 18 Aug 2026 19:08:22 -0500
Subject: [PATCH 11/13] fix(guilds): do not offer linking where linking cannot
reach
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
server/commands/guild.command.js | 24 ++++++++++++++++++------
server/test/guildCommand.test.js | 9 +++++++++
2 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/server/commands/guild.command.js b/server/commands/guild.command.js
index 2958a56..9908016 100644
--- a/server/commands/guild.command.js
+++ b/server/commands/guild.command.js
@@ -42,13 +42,22 @@ async function levelFor(actor) {
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
}
-// The nudge §9 answer 5 asks for, and only when it is TRUE. An unlinked caller
-// who was told nothing because the shard publishes nothing is not helped by
-// being invited to link; the prompt appears when linking is what would actually
-// change the answer.
+// The nudge §9 answer 5 asks for, and only when it is TRUE.
+//
+// **Linking reaches exactly two rungs and no further.** Signing in gets a caller
+// to `logged_in` and linking a game account to `player`; `staff` and `admin` are
+// roles an operator grants and no amount of linking will earn. So a shard that
+// gates guilds to staff refuses an unlinked caller WITHOUT the invitation —
+// telling them to link would be telling them to do something that changes
+// nothing, which is worse than saying no.
+//
+// The live walk found this: gated to `staff`, the refusal still read "this shard
+// shows guild information to linked players".
+const LINKING_REACHES = new Set(['logged_in', 'player'])
+
function linkPrompt(actor, audience) {
if (actor.isLinked) return null
- if (audience === 'anonymous') return null
+ if (!LINKING_REACHES.has(audience)) return null
return 'Link your account on the site to see more — this shard shows guild information to linked players.'
}
@@ -154,7 +163,10 @@ async function handler({ options, actor }) {
if (!wanted) {
const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT)
return {
- title: `Guilds on ${core.baseUrl.replace(/^https?:\/\//, '')}`,
+ // Not "Guilds on ": `ctx.site` carries a base URL and no brand name,
+ // so naming the deployment here can only mean printing its hostname into
+ // an embed title, which is noise on a shard's own Discord server.
+ title: 'Guilds on this shard',
fields: top.map((g) => ({
name: g.abbr ? `${g.name} [${g.abbr}]` : g.name,
value: `${g.members || 0} members · ${g.online || 0} online`,
diff --git a/server/test/guildCommand.test.js b/server/test/guildCommand.test.js
index af33659..7782632 100644
--- a/server/test/guildCommand.test.js
+++ b/server/test/guildCommand.test.js
@@ -88,6 +88,14 @@ test('an unlinked caller is invited to link — but only when linking would chan
stub({ audience: 'anonymous', level: 'anonymous' })
const open = await command.handler({ options: {}, actor: anonymous })
assert.equal(open.notice, null)
+
+ // Gated to staff: linking reaches `player` and stops there, so the invitation
+ // would be an instruction to do something that changes nothing. Found on the
+ // live rig, where a staff-gated shard still offered it.
+ stub({ audience: 'staff', level: 'anonymous' })
+ const unreachable = await command.handler({ options: {}, actor: anonymous })
+ assert.match(unreachable.text, /not shown to your account/)
+ assert.equal(unreachable.notice, null)
})
test('a stale board answers offline rather than reporting what it still holds', async () => {
@@ -99,6 +107,7 @@ test('a stale board answers offline rather than reporting what it still holds',
test('no argument lists the largest guilds', async () => {
stub()
const res = await command.handler({ options: {}, actor: anonymous })
+ assert.equal(res.title, 'Guilds on this shard')
assert.equal(res.fields.length, 2)
assert.match(res.fields[0].name, /Knights of the Codex/)
assert.match(res.fields[0].value, /12 members · 3 online/)
--
2.49.1
From 1a13f680f5d7aedf206810142078f0d7fbcc6b74 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 19 Aug 2026 01:15:49 -0500
Subject: [PATCH 12/13] feat(guilds): name the core contribution each declared
slot wants
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
---
client/src/entry.jsx | 15 +++++++-----
client/test/registration.test.js | 40 ++++++++++++++++++++++----------
2 files changed, 37 insertions(+), 18 deletions(-)
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index 90209b3..3a9b142 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -189,10 +189,13 @@ registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// populates, but core does not own the word "guild" and publishes no Team page of
// its own — so the page is ours and core contributes the activity feed to it.
//
-// Declared under this module's own namespace, which core enforces. Core's fill is
-// applied after every module chunk has evaluated, so declaring it here is early
-// enough; on a core that knows nothing of Teams it simply stays empty.
-registry.declareModuleSlot(ID, 'uo.guild.detail')
+// Declared under this module's own namespace, which core enforces. The second
+// argument is what gets core's content into the place: **core offers a
+// CONTRIBUTION and never names a slot**, so this module says where each one goes
+// and keeps its own word for the place. Core's fills are applied after every
+// module chunk has evaluated, so declaring here is early enough; on a core that
+// knows nothing of Teams the slot simply stays empty.
+registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
// declarations rather than one, because a slot holds one component and this module
@@ -200,14 +203,14 @@ registry.declareModuleSlot(ID, 'uo.guild.detail')
// the feed reads as part of the guild's story, the forum is a room you go into.
// Neither knows the other exists, and a core that fills only one leaves the other
// empty.
-registry.declareModuleSlot(ID, 'uo.guild.forum')
+registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// And a THIRD, at the top of the same page, for core's per-Team notification
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
// muting a guild is an action ON this page, so it sits with the page's heading
// rather than after its content. Core resolves whether this viewer is in the
// Team at all — this module neither knows nor asks.
-registry.declareModuleSlot(ID, 'uo.guild.header')
+registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a
diff --git a/client/test/registration.test.js b/client/test/registration.test.js
index 9e41927..8a57733 100644
--- a/client/test/registration.test.js
+++ b/client/test/registration.test.js
@@ -39,12 +39,18 @@ const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
// nothing here renders, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
+// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than
+// imported — this suite runs against the BUILT chunk with no core in the process
+// — which means it is a claim about core that has to be re-read when core's list
+// changes. That is the same trade the rest of this fake makes.
+const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
+
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
- const declaredSlots = new Set()
+ const declaredSlots = new Map()
return {
version: '1.3.0',
react,
@@ -74,13 +80,18 @@ function fakeRg() {
extensions.set(slot, { id, Component })
},
// The INVERTED direction (core API 1.6.0): this module declares a place on
- // its OWN page and core fills it. Core enforces the namespace, so the fake
- // does too — a chunk that declared an unnamespaced slot would pass here and
- // throw in a browser.
- declareModuleSlot(id, name) {
+ // its OWN page and core fills it. Core enforces the namespace and the
+ // contribution name, so the fake does too — a chunk that declared an
+ // unnamespaced slot, or asked for a contribution core does not offer, would
+ // pass here and throw in a browser.
+ declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
- declaredSlots.add(name)
+ const wants = options.core ?? null
+ if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
+ throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`)
+ }
+ declaredSlots.set(name, wants)
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
@@ -209,7 +220,7 @@ it('registers under exactly one module id, matching the manifest', () => {
assert.deepEqual([...owners], [manifest.id])
})
-it('declares its own guild slots, for core to fill', () => {
+it('declares its own guild slots, each naming the core contribution it wants', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
@@ -219,10 +230,15 @@ it('declares its own guild slots, for core to fill', () => {
// away this module's ability to place them separately on its own page — and it
// does place them separately, the control above the roster and the other two
// below it.
- assert.deepEqual([...registered.declaredSlots], [
- 'uo.guild.detail',
- 'uo.guild.forum',
- 'uo.guild.header',
+ //
+ // The second argument is what actually gets core's content here. **Core offers
+ // a contribution and never names a slot** — the first cut of this reached only
+ // this module, because core filled the literal name `uo.guild.detail` and any
+ // other game's page went empty with no error.
+ assert.deepEqual([...registered.declaredSlots.entries()], [
+ ['uo.guild.detail', 'team.activity'],
+ ['uo.guild.forum', 'team.forum'],
+ ['uo.guild.header', 'team.notify'],
])
})
@@ -230,7 +246,7 @@ it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
- for (const name of registered.declaredSlots) {
+ for (const name of registered.declaredSlots.keys()) {
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
}
})
--
2.49.1
From fe176920c561312057919c3e39c9716a7ac55d99 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 19 Aug 2026 04:00:01 -0500
Subject: [PATCH 13/13] ci(core-ref): pin to the Teams cutover, not a phase 3
edge sha
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
---
ci/core-ref.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/ci/core-ref.json b/ci/core-ref.json
index 023530a..3119e37 100644
--- a/ci/core-ref.json
+++ b/ci/core-ref.json
@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
- "ref": "7ed2ac99838f4bd64e1df324fe4961673648b0e6",
- "refName": "edge @ Teams phase 3 (website#152)"
+ "ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
+ "refName": "main @ the Teams cutover (website#161)"
}
--
2.49.1