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:
2026-08-19 01:21:55 -05:00
parent 77418aaef5
commit 7875848ee7
25 changed files with 1889 additions and 43 deletions

View File

@@ -149,9 +149,9 @@ Two more things that look like your module failing and are not:
Restart core and read the log. A module that loaded says so:
```
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world"}
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world"]}}
INFO [modules] schema ensured for module "examplegame" {"statements":2}
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world,/clans"}
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world","/clans"]}}
INFO [modules] schema ensured for module "examplegame" {"statements":4}
INFO [examplegame:boot] booted {"refreshMs":30000}
INFO [modules] module "examplegame" started
```
@@ -165,7 +165,13 @@ Then, in the browser:
- **`/examplegame/status`** renders your page, with a **World** row in the public
header pointing at it. That row is now an ordinary nav row: an operator can
reorder it, relabel it or hide it from the nav editor exactly as they can core's.
- **`/api/v1/public/world/status`** answers JSON.
- **`/examplegame/clans`** lists the two clans the template seeds at boot, and one
of them renders at `/examplegame/clans/clan-1` — the page that declares three
places for core to fill. On a core with Teams those hold the activity feed, the
forum and the notification control; on one without, they render nothing and the
page is exactly as complete. Both are correct outcomes and neither logs anything.
- **`/api/v1/public/world/status`** answers JSON, and so does
`/api/v1/public/clans`.
- **`/api/v1/public/modules`** lists you, with the `capabilities` array from your
`module.json`. This is how a client — core's SPA, the Android app, anything —
feature-detects you.

View File

@@ -147,7 +147,7 @@ statement of your dependencies, and it makes a test double for it — see
## What you register
Seven calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
is not their signatures but the model behind them.
**Every call stages; nothing is committed until your whole module is known good.**
@@ -183,8 +183,8 @@ An operator looking at a user in the admin panel wants that user's characters
right there, not on a separate screen.
`api.registerExtension(slot, router)` mounts your routes under a core resource,
and its client twin renders your component inside a core page. **Only core may
declare a slot; a module may only fill one**, and one module per slot.
and its client twin renders your component inside a core page. Core declares the
slot, you fill it, and one module per slot.
The naming rule is worth internalising, because it is what keeps a game-agnostic
core game-agnostic: **a slot is named for a PLACE, never for a meaning.**
@@ -194,6 +194,63 @@ styling; the module owns the label, the target, the data, and whether it renders
anything at all. The moment core types a slot by its content, it has re-acquired
the semantics the module system exists to remove.
### Slots go the other way too
The direction above assumes core owns the page. Since `MODULE_API_VERSION` 1.6.0
there is the mirror of it, and **you will need it the moment your game has
anything like a guild**: a module declares a place on its own page and core fills
it.
```jsx
// client/src/entry.jsx — WHERE, in your words, and WHICH of core's contributions
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
// client/src/routes/public/Clan.jsx — from the UI kit
<Slot name="examplegame.clan.detail" externalId={externalId} moduleId="examplegame" />
```
**Why it has to invert.** A Team is a core entity — core owns the tables, the
membership sync, the access rules, the forum, the activity feed. What core does
not own is the *word*. A UO shard says guild, yours will say clan or company or
crew, and a core-rendered `/teams` page would publish a noun core invented, beside
your own page for the same thing. So the page is yours, and the parts core cannot
hand over are contributed into it. What core cannot hand over is the test for
whether something belongs in a slot: the activity feed's public/members split can
only be resolved by whatever owns membership, and that is core. You could render a
feed; you could not decide who sees which half of it.
Four rules, and the first two are the ones the shape depends on:
- **Your slot name is namespaced under your module id**, enforced rather than
conventional. It is what keeps two modules from claiming one name, and it makes
the owner readable where the slot is rendered.
- **Core names a CONTRIBUTION, never your slot.** `team.activity`, `team.forum`
and `team.notify` are core's three; the place they land in is yours to name and
yours to position. This is the half a second game depends on, and the first cut
of 1.6.0 had it the other way round — core filled three literal slot names
belonging to the first module, so everyone else's page came up empty with
nothing logged. This kit is what found that.
- **One slot per PLACE, not one per page.** A slot holds one component, so three
contributions want three declarations — and then you decide where each sits. The
template puts the notification control above its roster because muting is an
action *on* the page, and the feed and forum below it because they are content
*in* it. That decision is the reason to declare three.
- **Asking for a contribution core does not offer throws**, which is unusual here
— the client registry otherwise 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 failure it would otherwise produce is a
page that renders empty forever.
`{ core }` is optional. A slot that asks for nothing stays empty, which is what
you want for a place you intend to fill yourself — and **first fill wins**, so a
module that fills its own declared slot keeps it and core's contribution is
skipped. The page is yours.
An empty slot renders nothing and is never an error: a core with no Teams, a
deployment with the forum switched off, a viewer with no membership. Design the
page to read correctly with every slot empty, because on some deployment it will
be.
### Notification streams, announce legs, post hooks
Three registries for three genuinely different things, and the distinctions are
@@ -222,6 +279,95 @@ Every hook is awaited and none may throw past core: a subscriber's failure costs
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
somebody's blog post edit would be a worse bug than a stale mirror.
### Becoming the source of Teams
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
this one is not like the others.
**Every registration up to here hands core something to hold.** A router to
mount, a nav row to draw, a hook to call when a post is saved. This hands core
something it will *pick up and call*, from its own reconciler, and — for the
optional fourth method — on a request path with a visitor waiting. It is the
first place in this contract where **core calls you and waits**, and every rule
below falls out of that one fact.
The three required methods answer the three questions core has about the Teams
you are authoritative for: what Teams exist, who is in one, and which of those
lead. `template/server/model/clans/clanProvider.model.js` is a working one,
including the guard clauses; the shape is:
```js
getTeams() // () => { ok, complete?, teams: [{ externalId, name, abbr?, meta? }] }
getTeamMembers(externalId) // => { ok, complete?, members: [{ memberKey, displayName?, rankLabel?,
// leader?, online?, userId? }] }
getTeamLeaders(externalId) // => { ok, leaders: [memberKey] }
// the module knows it cannot answer — sidecar down, cache cold, boot unfinished
{ ok: false, reason: 'sidecar unreachable' }
```
**The envelope is the contract, and it is not decoration.** A rejected promise, a
synchronous throw, a timeout past core's ten-second budget, a non-object, a
missing `ok`, a malformed row — core reads every one of them as `{ ok: false }`.
There is no shape a failure can take that core reads as "zero Teams". That is the
whole argument for it: a bare array has exactly one such shape, `[]`, and it is
the one you return while your sidecar is still connecting.
**So refusing is normal.** `{ ok: false }` is an ordinary answer, not an error you
failed to handle. Core keeps the projection it has, records your reason and shows
it to an operator. A refusal costs staleness and nothing else.
**The mistake to not make** is answering `{ ok: true, teams: [] }` because your
game is unreachable. It reads as an authoritative "this deployment has no Teams",
and core acts on authoritative answers — it archives Teams that have stopped
existing and departs members who have left. A cold start would empty every roster
on the site, and your module would have done it by being helpful. The template's
provider therefore refuses whenever its data might be stale, *even though the rows
it holds are perfectly readable*: core cannot tell a snapshot five minutes old
from one five days old, and it makes destructive decisions from a complete answer.
Same reasoning one level down — an empty roster is refused unless the game says
the Team is empty, because the Team and its roster arrive on separate frames in
any real ingest and there is a window where you know one and not the other.
**`projectRoster(externalId, members, viewer)` is optional and fails CLOSED**, and
that asymmetry is the part worth carrying away. It answers *who may look at this
roster*, on the request path, because the audience model is yours — core does not
know what your rungs are called and cannot invent one. For the other three, an
unanswered call must change nothing. For this one, "keep what you have" means
serving the roster unprojected to whoever asked, which is a leak. So core
distinguishes two refusals and you get the right one for free:
- **no provider, or no `projectRoster`** — nothing is being withheld, so core
serves the roster whole at its own public shape. That is what makes the method
genuinely optional.
- **a `projectRoster` that refused, threw, timed out or answered malformed** — core
serves an empty roster and says so. You claimed an opinion and then did not give
it.
Two smaller things the template gets right and are easy to get wrong: it hands
back the member keys **core** supplied (core's rows, core's `member_key` spelling)
rather than its own, and it treats an anonymous viewer — core hands over `null`
as an *answer* rather than as a lookup that failed. The second one refuses on
every anonymous visit, which on a public deployment is most of your traffic.
**One provider per deployment.** Unlike every other registry this holds a single
value: two modules answering "what Teams exist" would produce two disjoint sets
under one table with no rule for merging them.
**`pageUrlTemplate` is data, not a method** — `'/examplegame/clans/{externalId}'`
— and it is the fifth member. Teams have no core page, so core cannot work out
where yours is, and a notification email about a forum reply that cannot link to
the thread is most of the way to useless. A relative path only; core substitutes
`{externalId}` and `{slug}` and does nothing else with it. Data rather than a
callback deliberately: a function here would put a module hook on the mail path,
one more thing that can hang, to produce a string that never varies.
**What core never gets is your tables.** It asks the questions; you own the
storage, the ingest and the game↔site account mapping (`userId` on a member is
resolved by you, because a core that resolved it would be core reading a module's
table by name). The traffic in the other direction is `ctx.teams.*`, and it is
narrow on purpose.
### The lifecycle hooks
`api.onBoot(fn)` runs after core's schema, after your schema fragment, and
@@ -388,9 +534,16 @@ every case than one that shows a link which then answers `403`.
Core publishes a small set of components and hooks on `window.__rg.ui`
([§3.4][api] is the list): the public layout, a page header, the loading, error
and empty states, the async hook every data page uses, and read-only access to the
session and site settings. Enough to build a page that looks like the site it is
installed in, and nothing else.
and empty states, the async hook every data page uses, read-only access to the
session and site settings, and `Slot`. Enough to build a page that looks like the
site it is installed in, and nothing else.
`Slot` is the odd one — not a widget but the thing that renders a place you
declared for core, from ["Slots go the other way too"](#slots-go-the-other-way-too)
above. It is in the kit rather than left to you for the reason the kit exists at
all: reimplementing it would mean a second error boundary with different
behaviour, and what this one contains is *core's* content failing inside *your*
page.
**`PublicLayout` needs a `shell`, and this is the one that will catch you.** The
layout is the *chrome* — header, footer, the flex column they sit in. The `shell`
@@ -514,6 +667,7 @@ where you publish.
| Do not read `process.env` for core configuration | Configuration with two sources and no panel. Your own config is a settings key or your own table. |
| No `process.exit`, no signal handlers, no listeners | A module taking the site down, or racing core's shutdown. |
| Write only inside your module root and the upload directory | A module that cannot be uninstalled cleanly. |
| Never read or write a core table — including the Team tables you populate | A module racing core's own reconciler for rows core owns. You answer questions about Teams; core stores them. |
| **Never open a connection to a game server from the website process** | The whole of [chapter 3](03-sidecar.md). |
That last one is newer than the others and is the reason this kit is three