From dda0e32dd38b07741ad655bd98033d454445c9da Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:58:30 -0500 Subject: [PATCH] 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 ( + + + {m.name || 'Unknown'} + {m.rank === 4 && ( + Leader + )} + + {rank || '—'} + + {linked ? 'Linked' : '—'} + + + ) +} + +export default function Guild() { + const { id } = useParams() + const { loading, error, data } = useAsync(() => api.shard.guild(id), [id]) + const roster = (data && data.roster) || [] + + return ( + +
+

+ ← All guilds +

+ + {loading && } + {error && } + + {!loading && !error && data && ( + <> + +

+ {data.members ?? roster.length} members + {data.online != null && ` · ${data.online} online`} + {data.alliance && ` · ${data.alliance}`} +

+ + {roster.length > 0 && ( +
+ + + + + + + + + + {/* Keyed by serial: two characters can share a display name, + which this shard's own world actually contains. */} + {roster.map((m) => )} + +
NameRankAccount
+
+ )} + + {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": [