feat(guilds): the roster projection, a guild detail page, and the slot core fills #11

Merged
whitlocktech merged 2 commits from feat/teams-phase3-projection-slot into edge 2026-08-18 02:12:20 +00:00
11 changed files with 257 additions and 80 deletions
Showing only changes of commit dda0e32dd3 - Show all commits

View File

@@ -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}` : '')}`),

View File

@@ -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 (
<p
className="sans dim"
style={{ margin: '0 0 8px', fontSize: '0.86rem' }}
>
{total === 0
? 'Nobody is on the shard right now.'
: `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`}
{' '}
<span style={{ opacity: 0.7 }}>
The per-member marks above are as recent as the last roster sync.
</span>
</p>
)
}

View File

@@ -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.

View File

@@ -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: <ShardActivity /> },
{ path: 'champs', element: <ChampSpawns /> },
{ path: 'guilds', element: <Guilds /> },
{ path: 'guilds/:id', element: <Guild /> },
{ path: 'governors', element: <Governors /> },
{ path: 'houses', element: <Houses /> },
{ path: 'rules', element: <Rules /> },
@@ -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

View File

@@ -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 (
<tr style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '9px 10px', color: 'var(--head)' }}>
{m.name || 'Unknown'}
{m.rank === 4 && (
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
)}
</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>{rank || '—'}</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>
{linked ? 'Linked' : '—'}
</td>
</tr>
)
}
export default function Guild() {
const { id } = useParams()
const { loading, error, data } = useAsync(() => api.shard.guild(id), [id])
const roster = (data && data.roster) || []
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p style={{ marginBottom: 14 }}>
<Link to="/uo/guilds"> All guilds</Link>
</p>
{loading && <Loading />}
{error && <ErrorState message="Could not load this guild right now." />}
{!loading && !error && data && (
<>
<PageHeader
eyebrow={data.abbr ? `[${data.abbr}]` : 'Guild'}
title={data.name || 'A guild'}
/>
<p className="sans dim" style={{ fontSize: '0.88rem' }}>
{data.members ?? roster.length} members
{data.online != null && ` · ${data.online} online`}
{data.alliance && ` · ${data.alliance}`}
</p>
{roster.length > 0 && (
<div style={{ overflowX: 'auto', marginTop: 18 }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Name</th>
<th style={{ padding: '8px 10px' }}>Rank</th>
<th style={{ padding: '8px 10px' }}>Account</th>
</tr>
</thead>
<tbody>
{/* Keyed by serial: two characters can share a display name,
which this shard's own world actually contains. */}
{roster.map((m) => <MemberRow key={m.serial} m={m} />)}
</tbody>
</table>
</div>
)}
{roster.length === 0 && (
<p className="sans dim" style={{ marginTop: 18 }}>No roster has been received for this guild yet.</p>
)}
{/* 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. */}
<Slot name="uo.guild.detail" externalId={String(id)} moduleId="uo" />
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -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 (
<div
// A link now, because the board gained a detail page: the roster and core's
// Team activity feed live there (docs/website/TEAMS.md Part 3).
<Link
to={`/uo/guilds/${encodeURIComponent(g.id)}`}
className="panel"
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
<Leader leader={g.leader} />
</div>
</div>
</div>
</Link>
)
}

View File

@@ -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"/)
})

View File

@@ -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",

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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": [