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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user