feat(kit): the two shapes Teams added, taught and built
MODULE_API 1.6.0 expands the contract this book teaches against, so the book
owes two shapes and one correction. Chapter 2 gains both and the template grows
a working version of each, because a reader following a snippet has no way to
find out whether it runs.
ONE SENTENCE WAS WRONG. Chapter 2 said, of extension slots, "Only core may
declare a slot; a module may only fill one". 1.6.0 inverted exactly that: a
module declares a place on its OWN page and core fills it. That is not a stale
detail - a new game's module cannot implement Teams at all without the inverted
direction, so it is the shape the reader needs and did not have.
THE TWO SHAPES
- The inverted slot. A new "Slots go the other way too" section: why the
direction has to invert (core owns the Team, not the word for one), the
namespace rule, one slot per PLACE, the optional { core } naming which of
core's three contributions goes there, and why asking for one core does not
offer throws when almost everything else in that registry fails open.
- registerTeamProvider, in "Becoming the source of Teams". The first
registration where core calls YOU and waits, which is where every rule in it
comes from: the envelope, the ten-second budget, refusing as a normal answer,
and the one mistake worth naming - answering with an empty list because the
game is unreachable, which core reads as authoritative and acts on.
projectRoster gets its own treatment because it is the exception that fails
CLOSED. pageUrlTemplate is a footnote beside it, as intended.
WHAT THE TEMPLATE GREW
model/clans/ - the provider over two tables, with the guards that matter: an
unreachable game refuses rather than reporting no clans, an empty roster is
refused unless the game says the clan is empty (which is why the schema keeps a
member count the rows cannot supply), and the audience rule lives in one file
that both projectRoster and the module's own page consult, because a second copy
drifts in the direction that publishes what core is withholding.
Its own /clans routes, deliberately not /teams - core mounts that itself, and
the loader would refuse the collision. A clan list page and a clan page that
declares three slots for core.
12 provider tests and three registration tests, 47 server and 20 client in
total. The purge test finally proves something: two of the three tables are now
a parent and its child.
WHAT IT DOES NOT DO. Enumerate the contract. The kit teaches one path end to end
and links out; it has never mentioned three pre-Teams registrations and that is
the design, not a gap.
FOUND WHILE WRITING IT: core filled three literal uo.guild.* slot names, so the
inverted direction reached exactly one module and every other game's page came
up empty with nothing logged. Fixed in website#160 / Module-uo#15 / docs#165
before this chapter could teach it - which is what this phase is for.
The ci/core-ref.json pin moves in a later commit on this branch: checkCoreApi is
an equality against a core on main, and 1.6.0 does not reach main until the
cutover.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,8 +27,16 @@ export const world = {
|
||||
status: () => req('/public/world/status'),
|
||||
}
|
||||
|
||||
// The module's own clan surface. Core serves its own view of the same things as
|
||||
// Teams, at `/public/teams` — which is why the prefix here is `/clans` and could
|
||||
// not be `/teams`; see `server/router/public/clans.router.js`.
|
||||
export const clans = {
|
||||
list: () => req('/public/clans'),
|
||||
get: (externalId) => req(`/public/clans/${encodeURIComponent(externalId)}`),
|
||||
}
|
||||
|
||||
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||
// download link, an EventSource. Reach for `request` first.
|
||||
export { BASE }
|
||||
|
||||
export default { world, BASE }
|
||||
export default { world, clans, BASE }
|
||||
|
||||
@@ -45,10 +45,17 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
|
||||
)
|
||||
}
|
||||
|
||||
// The curated kit (§3.4). Eight exports, and it is CLOSED: layout, headings, the
|
||||
// three data-page states, the fetch hook, and read-only access to the session and
|
||||
// the site's settings. Anything else your pages need — tables, tabs, an editor —
|
||||
// you bundle yourself, in a `components/` directory of your own.
|
||||
// The curated kit (§3.4). Nine exports, and it is CLOSED: layout, headings, the
|
||||
// three data-page states, the fetch hook, read-only access to the session and the
|
||||
// site's settings, and `Slot`. Anything else your pages need — tables, tabs, an
|
||||
// editor — you bundle yourself, in a `components/` directory of your own.
|
||||
//
|
||||
// `Slot` is the one that is not a widget. It renders a place THIS module declared
|
||||
// for core to fill (`entry.jsx`, and `routes/public/Clan.jsx` where two are used):
|
||||
// the inverted direction of the extension-slot mechanism, added in 1.6.0. It is in
|
||||
// the shared kit rather than reimplementable for the reason the whole kit exists —
|
||||
// a second error boundary with different behaviour would be a second bug, and what
|
||||
// this one contains is CORE's content failing inside YOUR page.
|
||||
//
|
||||
// Closed is a real constraint and it is the price of the boundary being worth
|
||||
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing an
|
||||
@@ -64,6 +71,7 @@ export const {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
Slot,
|
||||
} = rg.ui
|
||||
|
||||
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
|
||||
import WorldStatus from './routes/public/WorldStatus.jsx'
|
||||
import Clans from './routes/public/Clans.jsx'
|
||||
import Clan from './routes/public/Clan.jsx'
|
||||
|
||||
// Your module id, exactly as `module.json` spells it. Core keys the registry by
|
||||
// it and prefixes every route path with it.
|
||||
@@ -42,6 +44,13 @@ const ID = 'examplegame'
|
||||
registry.registerRoutes(ID, {
|
||||
public: [
|
||||
{ path: 'status', element: <WorldStatus /> },
|
||||
{ path: 'clans', element: <Clans /> },
|
||||
// A parameter, and the name matters twice: `useParams()` in the page reads
|
||||
// `externalId`, and the server's `pageUrlTemplate` substitutes `{externalId}`
|
||||
// into this same path so core's notification email can link here. Nothing
|
||||
// checks those three against each other — this is the seam to get right by
|
||||
// hand, and the cost of getting it wrong is mail linking at a page that 404s.
|
||||
{ path: 'clans/:externalId', element: <Clan /> },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -68,9 +77,58 @@ registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [
|
||||
{ label: 'World', to: '/examplegame/status' },
|
||||
// The clan PAGE gets no nav row: rows point at pages a visitor can reach
|
||||
// without knowing an id, and `/examplegame/clans/:externalId` is not one.
|
||||
// `registration.test.js` checks every row against a route this module
|
||||
// registered, which is the agreement that rots quietly.
|
||||
{ label: 'Clans', to: '/examplegame/clans' },
|
||||
],
|
||||
})
|
||||
|
||||
// ── The inverted slot: this module DECLARES, core fills ───────────────────
|
||||
//
|
||||
// Everywhere else, core declares a place and a module fills it
|
||||
// (`registry.registerExtension`). This is the mirror, added in MODULE_API 1.6.0
|
||||
// for Teams: **a module declares a place on its own page and core fills it.**
|
||||
//
|
||||
// Teams are a core primitive with no core surface — core owns the tables, the
|
||||
// membership sync, the access rules, the forum and the feed, and does not own the
|
||||
// word "clan" — so the page is this module's and core contributes into it.
|
||||
//
|
||||
// Each declaration says two things: WHERE, in this module's own vocabulary, and
|
||||
// WHICH of core's contributions belongs there. **Core offers a contribution and
|
||||
// never names a slot** — it cannot, since it does not know what you called your
|
||||
// page — so the second argument is the whole of what gets core's content onto it.
|
||||
// Core's three, as of 1.6.0:
|
||||
//
|
||||
// `team.activity` the Team activity feed
|
||||
// `team.forum` the Team forum panel
|
||||
// `team.notify` the per-Team notification control
|
||||
//
|
||||
// Four things about these three lines:
|
||||
//
|
||||
// • **The name must be namespaced under this module's id**, and core enforces
|
||||
// that rather than trusting it. It is what keeps two modules from claiming one
|
||||
// name, and it makes the owner readable at the point of use in `Clan.jsx`.
|
||||
// • **One slot per PLACE, not one per page.** A slot holds one component, so
|
||||
// three contributions need three declarations — and this module then decides
|
||||
// where each one sits, which is the freedom it declared them for.
|
||||
// • **Asking for a contribution core does not offer THROWS here**, unlike almost
|
||||
// everything else in the registry, which fails open. Core's catalogue is fixed
|
||||
// at build time and your `coreApi` range has already been checked, so an
|
||||
// unknown one is always a typo or a version skew — and the alternative failure
|
||||
// is a page that renders empty forever with nothing logged.
|
||||
// • **`{ core }` is optional.** A slot that asks for nothing stays empty, which
|
||||
// is what you want for a place you intend to fill yourself.
|
||||
//
|
||||
// Declaring costs nothing on a core that offers none of them: core's fills are
|
||||
// applied after every module chunk has evaluated, and a contribution nothing asks
|
||||
// for is a no-op rather than an error. Both directions of that are silent on
|
||||
// purpose — neither side may assume the other is there.
|
||||
registry.declareModuleSlot(ID, 'examplegame.clan.header', { core: 'team.notify' })
|
||||
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
|
||||
registry.declareModuleSlot(ID, 'examplegame.clan.forum', { core: 'team.forum' })
|
||||
|
||||
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
||||
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
||||
// between the core that validated your manifest and the core that published this
|
||||
|
||||
113
template/client/src/routes/public/Clan.jsx
Normal file
113
template/client/src/routes/public/Clan.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// ── One clan — and the page that inverts the extension-slot direction ─────
|
||||
//
|
||||
// Everywhere else in this template, core owns a page and this module contributes
|
||||
// to it. Here it is the other way round: **this module owns the page and core
|
||||
// contributes to it**, through slots this module declared in `entry.jsx`.
|
||||
//
|
||||
// **Why it has to be this way round.** A Team is a core primitive — core owns the
|
||||
// tables, the membership sync, the access rules, the forum and the activity feed
|
||||
// — but core has no word for one. This game says clan, the next will say company,
|
||||
// and a core-rendered `/teams` page would publish a noun core invented, beside
|
||||
// this module's own page for the same thing. So the page is the module's, and the
|
||||
// parts core cannot hand over are contributed into it.
|
||||
//
|
||||
// What core cannot hand over is worth being concrete about, because it is the
|
||||
// test for whether something belongs in a slot: the activity feed's public/members
|
||||
// split can only be resolved by the thing that owns membership, which is core.
|
||||
// This module could render a feed; it could not decide who sees which half of it.
|
||||
//
|
||||
// **Three properties of `Slot` to know before you use one:**
|
||||
//
|
||||
// • It renders NOTHING when nothing fills it. A core that knows no Teams, a
|
||||
// deployment with the forum switched off, a viewer with no membership — all
|
||||
// of them are an empty slot and none of them is an error. Design the page to
|
||||
// read correctly with every slot empty, because on some deployment it will.
|
||||
// • **First fill wins**, and this module could fill its own declared slot. It
|
||||
// does not, and that is the point of declaring one — but the rule is there so
|
||||
// that a module can override core's contribution on a page it owns.
|
||||
// • `externalId` is what core resolves the Team from, in THIS module's terms.
|
||||
// Core maps its own Team from `(moduleId, externalId)`; the module never
|
||||
// learns core's Team id and does not need to.
|
||||
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
export default function Clan() {
|
||||
const { externalId } = useParams()
|
||||
const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
|
||||
|
||||
return (
|
||||
<PublicLayout shell="narrow">
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState error={error} />}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<PageHeader
|
||||
title={data.name}
|
||||
subtitle={`${data.memberCount} members${data.abbr ? ` · ${data.abbr}` : ''}`}
|
||||
/>
|
||||
|
||||
{/* Core's per-Team notification control lands here — ABOVE the roster,
|
||||
deliberately. Muting a clan is an action ON this page, so it belongs
|
||||
beside the heading rather than after the content. That placement is
|
||||
this module's decision to make, and it is the whole reason for
|
||||
declaring three slots rather than one: a single slot would hand core
|
||||
the choice of where each of its contributions sits on a page core
|
||||
does not own. */}
|
||||
<Slot name="examplegame.clan.header" externalId={externalId} moduleId="examplegame" />
|
||||
|
||||
{data.members.length > 0 ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', opacity: 0.7 }}>
|
||||
<th style={{ padding: '0.4rem 0.5rem' }}>Name</th>
|
||||
<th style={{ padding: '0.4rem 0.5rem' }}>Rank</th>
|
||||
<th style={{ padding: '0.4rem 0.5rem' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.members.map((m) => (
|
||||
<tr key={`${m.displayName}-${m.rankLabel}`}>
|
||||
<td style={{ padding: '0.4rem 0.5rem' }}>
|
||||
{m.displayName}{m.leader ? ' ★' : ''}
|
||||
</td>
|
||||
<td style={{ padding: '0.4rem 0.5rem' }}>{m.rankLabel || '—'}</td>
|
||||
<td style={{ padding: '0.4rem 0.5rem' }}>{m.online ? 'online' : 'offline'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
// Three quite different things produce an empty roster, and the server
|
||||
// says which: a clan with nobody in it, an audience rule that excludes
|
||||
// this viewer, and a rule nobody could resolve. A page that cannot tell
|
||||
// them apart reports the last as the first.
|
||||
<p style={{ opacity: 0.7, marginTop: '1rem' }}>
|
||||
{data.projected
|
||||
? 'No roster has been reported for this clan yet.'
|
||||
: 'The roster is not available to you right now.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Core's Team activity feed. It is core's because only core can
|
||||
resolve the public/members split on it — this module owns who is in
|
||||
the clan, core owns what being in one entitles you to see. */}
|
||||
<Slot name="examplegame.clan.detail" externalId={externalId} moduleId="examplegame" />
|
||||
|
||||
{/* And core's Team forum, in its own place below the feed. Core resolves
|
||||
who may read and post; this module renders the room and never its
|
||||
door policy. Empty on a deployment with forums switched off, which is
|
||||
the default. */}
|
||||
<Slot name="examplegame.clan.forum" externalId={externalId} moduleId="examplegame" />
|
||||
|
||||
<p style={{ marginTop: '1.5rem' }}>
|
||||
<Link to="/examplegame/clans">← All clans</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
50
template/client/src/routes/public/Clans.jsx
Normal file
50
template/client/src/routes/public/Clans.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// ── The clan list ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// An ordinary index page, here mostly so the clan page below it has somewhere to
|
||||
// be linked from. The interesting file is `Clan.jsx`.
|
||||
//
|
||||
// `Link` comes from `react-router-dom`, which resolves through this module's shim
|
||||
// to core's router — so a click navigates inside the SPA rather than reloading
|
||||
// the site. An `<a href>` here would work and would cost a full page load and the
|
||||
// session-shaped flash that comes with it.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
export default function Clans() {
|
||||
const { data, loading, error } = useAsync(() => api.clans.list(), [])
|
||||
|
||||
return (
|
||||
<PublicLayout shell="narrow">
|
||||
<PageHeader title="Clans" subtitle="The companies, orders and warbands of the world" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState error={error} />}
|
||||
|
||||
{data && data.clans.length === 0 && (
|
||||
<EmptyState message="No clans have been reported yet." />
|
||||
)}
|
||||
|
||||
{data && data.clans.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: '0.5rem' }}>
|
||||
{data.clans.map((clan) => (
|
||||
<li key={clan.externalId}>
|
||||
<Link to={`/examplegame/clans/${clan.externalId}`}>
|
||||
{clan.name}{clan.abbr ? ` [${clan.abbr}]` : ''}
|
||||
</Link>
|
||||
<span style={{ opacity: 0.7 }}> — {clan.memberCount} members</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && data.stale && (
|
||||
<p style={{ opacity: 0.7, marginTop: '1rem' }}>
|
||||
The game has not reported recently, so this list may be out of date.
|
||||
</p>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,12 @@ const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
|
||||
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
|
||||
|
||||
// Core's contribution catalogue, as of MODULE_API 1.6.0 (§3.7a). Written down
|
||||
// rather than imported: this suite runs against the BUILT chunk with no core in
|
||||
// the process, so it is a claim about core that has to be re-read when core's list
|
||||
// changes — the same trade the rest of this fake makes.
|
||||
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
|
||||
|
||||
// A component, as far as the registry cares. The kit's real members are core's;
|
||||
// nothing renders here, so a named stub is enough to be imported and passed on.
|
||||
const stub = (name) => Object.assign(() => null, { displayName: name })
|
||||
@@ -44,6 +50,7 @@ function fakeRg() {
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
const extensions = new Map()
|
||||
const declaredSlots = []
|
||||
return {
|
||||
version: manifest.coreApi.replace(/^\D+/, ''),
|
||||
react,
|
||||
@@ -54,7 +61,7 @@ function fakeRg() {
|
||||
// this 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 +79,22 @@ function fakeRg() {
|
||||
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
|
||||
extensions.set(slot, { id, Component })
|
||||
},
|
||||
// The INVERTED direction (1.6.0): the module declares, core fills. Core
|
||||
// enforces the namespace AND the contribution name at this call, which is why
|
||||
// the fake does too — either one core would reject is a slot that renders
|
||||
// nothing on a real install and everything in a suite that shrugged.
|
||||
declareModuleSlot(id, name, options = {}) {
|
||||
if (!name.startsWith(`${id}.`)) throw new Error(`"${name}" is not namespaced under "${id}"`)
|
||||
const wants = options.core ?? null
|
||||
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
|
||||
throw new Error(`"${name}" asks for core contribution "${wants}", which core does not offer`)
|
||||
}
|
||||
declaredSlots.push({ id, name, wants })
|
||||
},
|
||||
routesFor: (area) => routes[area],
|
||||
navFor: (area) => nav[area],
|
||||
},
|
||||
_read: () => ({ routes, nav, providers, extensions }),
|
||||
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,12 +185,38 @@ it('every slot module.json declares is one the chunk fills', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('every declared slot is namespaced under this module and rendered by a page', () => {
|
||||
// Two halves that nothing else holds together. The namespace is core's rule and
|
||||
// the fake enforces it at the call; what a test has to check is the OTHER end —
|
||||
// a slot declared and never rendered is a promise to core that no page keeps,
|
||||
// and it fails silently, because an unrendered slot looks exactly like an
|
||||
// unfilled one.
|
||||
const pages = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Clan.jsx'), 'utf8')
|
||||
for (const { id, name } of registered.declaredSlots) {
|
||||
assert.equal(id, manifest.id)
|
||||
assert.ok(name.startsWith(`${manifest.id}.`), `slot "${name}" is not under the module namespace`)
|
||||
assert.ok(pages.includes(`name="${name}"`), `slot "${name}" is declared and never rendered`)
|
||||
}
|
||||
})
|
||||
|
||||
it('every declared slot names a core contribution core actually offers', () => {
|
||||
// The fake throws on an unknown one, exactly as core does, so this asserts the
|
||||
// other half: that the slots asked for something at all. A slot with no `core`
|
||||
// is legal and stays empty — which is right for a place you fill yourself and
|
||||
// wrong for one you are waiting on core for, and only you know which it is.
|
||||
for (const { name, wants } of registered.declaredSlots) {
|
||||
assert.ok(wants, `slot "${name}" asks for no core contribution, so nothing will ever fill it`)
|
||||
assert.ok(CORE_CONTRIBUTIONS.includes(wants))
|
||||
}
|
||||
})
|
||||
|
||||
it('registers under exactly one module id, matching the manifest', () => {
|
||||
const owners = new Set([
|
||||
...Object.values(registered.routes).flat().map((r) => r.moduleId),
|
||||
...Object.values(registered.nav).flat().map((r) => r.moduleId),
|
||||
...[...registered.extensions.values()].map((e) => e.id),
|
||||
...[...registered.providers.values()].map((p) => p.id),
|
||||
...registered.declaredSlots.map((s) => s.id),
|
||||
])
|
||||
assert.deepEqual([...owners], [manifest.id])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user