feat(kit): the engagement contract, taught and built (cutover 5 of 7)
The kit was pinned to website 963d734 -- MODULE_API 1.6.0, the Teams cutover --
and the platform is on 1.9.0. Three registrations and two calls arrived in
between, and a reader building against this book would have found no mention of
any of them: a module can now declare what its game can announce, and never who
is told.
Moving `ci/core-ref.json` is the mechanism for exactly this. The pin is now
66bb3b9a (website `main`, the engagement cutover) and `template/module.json`
declares `^1.9.0`.
What chapter 2 gained, under "Telling core something happened":
* a TRIGGER is a payload contract, not a notification stream -- the two share
one id namespace and are constantly confused;
* `ceiling` is required, has no default, and is a CONTAINMENT tree rather than
a size ladder (a `staff` ceiling does not permit `owner`);
* an AUDIENCE resolver returns user ids and nothing else, resolves to NOBODY
on failure, and takes CONSTANT params -- the constraint worth knowing before
you design around it;
* templates re-ensure per seedVersion, rule groups are offered ONCE per group
key, so a rule appended to an existing group reaches fresh installs only;
* `ctx.events.emit` binds the owner and is fire-and-forget; `ctx.inbox.push`
is the direct write, for when there is nothing for an operator to decide.
The template builds all of it: one trigger, one audience over the clan roster it
already had, one seeded body and one seeded rule group, and an emitter in
`boot.js` that fires on the TRANSITION rather than on the poll. Seven new tests,
including the audience that resolves to nobody when its query throws.
Three claims were wrong and are corrected here rather than shipped:
* core validates `subjectKey` against the declared variables and refuses the
module; the draft taught a cooldown keyed on `undefined`, which the check
exists to prevent and a reader will never see.
* `emit` throws OUTSIDE production and only drops-and-logs inside it. Teaching
the second half alone leaves a developer meeting a throw the book says
cannot happen.
* the seeded body itself was malformed -- heading `level: 2` where the block
registry takes 'h2', and no block ids at all.
The third is the one worth keeping: `registerEngagementSeeds` checks that
`blocks` is a non-empty array and stops, so that body would have registered,
seeded, and failed the first time an operator opened it. Found by running the
template's `register()` through core's real registry at the pinned ref -- which
CI does not do, and cannot: the template job checks the version and runs the
template against fakes. A fake accepts what core refuses. The gap is now named
in the chapter, beside the code, and in the pin's own comment, and the rule that
bit has a test that fails on it.
Also: `checkLinks` skipped `.core/`. Bumping this pin means cloning core into
that directory first, and the walk then reported nine broken links in someone
else's README. CI never saw it -- the clone happens in the `template` job and
the check runs in `prose` -- so it was a failure only a person could meet.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
|
||||
|
||||
## What you register
|
||||
|
||||
Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
|
||||
is not their signatures but the model behind them.
|
||||
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
|
||||
all listed 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.**
|
||||
The shape of a claim is checked at the call, so a malformed one throws with your
|
||||
@@ -279,6 +280,119 @@ 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.
|
||||
|
||||
### Telling core something happened
|
||||
|
||||
Three registrations and one call, and together they are the seam where a module
|
||||
is most tempted to reach past the boundary. The rule that keeps them safe is one
|
||||
sentence: **you declare what CAN happen; core decides who is told.**
|
||||
|
||||
A **trigger** is not a notification stream, and the two are easy to confuse
|
||||
because both are catalogs of things that happen in your game. A stream is a
|
||||
subscribe toggle, and you publish to it yourself. A trigger is a **payload
|
||||
contract**: it names the variables an event carries and how wide an audience it
|
||||
may ever be given, an operator writes rules against it, and *core* does the
|
||||
sending. Their ids share one namespace, so declaring both for the same id is
|
||||
legal — that is one event with a toggle and a contract — while taking an id
|
||||
another module owns is not.
|
||||
|
||||
Two fields on a trigger are worth more than their size.
|
||||
|
||||
**`ceiling` is required and has no default, and the values are ordered by
|
||||
containment rather than by size.** It is the widest audience a rule on this
|
||||
trigger may ever be given. There is no safe value to guess: `owner` silently
|
||||
breaks a broadcast, `authenticated` silently widens something meant for staff.
|
||||
And the ladder reading of the seven values is the trap — a `staff` ceiling does
|
||||
**not** permit `owner`, because "one person" for a cheat-detection event is *the
|
||||
player it was detected on*. Fewer people is not less exposure.
|
||||
|
||||
**`subjectKey` must name one of your declared variables**, because it is what the
|
||||
cooldown is keyed on — "once per house", not "once per user". Core checks it at
|
||||
registration and refuses the module, so this is one you meet at your first boot
|
||||
rather than in production. The check is there because the failure it prevents is
|
||||
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
|
||||
which looks exactly like the feature working right up until two houses share it.
|
||||
|
||||
Every variable needs an `example`, and it is not decoration: it is what lets an
|
||||
operator preview and test-send a body without waiting for a real game event,
|
||||
which is the reason template systems ship untested. The type set is closed and
|
||||
has no `object` or `array` — a message that has to walk a structure has outgrown
|
||||
interpolation.
|
||||
|
||||
An **audience** is a named set of *people* you can resolve over your own data,
|
||||
for an operator to point a rule at. "This clan's members" is one. "Everyone who
|
||||
opened the last mail" is not, and nothing here builds it.
|
||||
|
||||
**Your resolver returns user ids and nothing else.** It is not handed a template,
|
||||
a channel or an address, and it cannot enumerate them; core maps ids to addresses
|
||||
on its own side, after preferences, suppression and the verification gate. That
|
||||
narrowness is deliberate — a module still cannot send mail, and this is the
|
||||
obvious place a back door would go. Two consequences follow from it:
|
||||
|
||||
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
|
||||
its last good answer. Core enforces that, and your resolver should choose it
|
||||
too, so the log can say which clan.
|
||||
- **Its params are CONSTANT.** An operator fills them in when they save the rule.
|
||||
There is no way to say "the clan this event was about" — if a rule needs that,
|
||||
the *event* carries its own recipients instead. This is the constraint most
|
||||
worth knowing before you design around it rather than after.
|
||||
|
||||
The third registration ships the **content**: the bodies your messages use and
|
||||
the rules that decide when one is sent. Both arrive **switched off**, and
|
||||
`enabled` is not a parameter. An operator turns a module's mail on; installing a
|
||||
module never does.
|
||||
|
||||
The two halves have different lifetimes, and the asymmetry is the contract.
|
||||
**Templates re-ensure on every boot** under a seed version, so a better default
|
||||
reaches deployments that never edited it while one an operator *has* edited is
|
||||
left alone. **Rule groups are offered once, per named group key**, because
|
||||
re-offering would resurrect a rule somebody deleted and reset one they enabled.
|
||||
The consequence is easy to trip over: a rule appended to an existing group
|
||||
reaches **fresh installs only**. That is the guarantee rather than a limitation
|
||||
to route around, and a rule that must reach existing deployments takes a new
|
||||
group key. You name the groups, so the choice is yours to make knowingly.
|
||||
|
||||
Core's generic bodies are a first-class answer rather than a fallback. Point a
|
||||
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
|
||||
own when the message has something to say that a structural projection of the
|
||||
payload cannot.
|
||||
|
||||
**One thing in a seeded body is not checked when you register it.** The call
|
||||
asserts that `blocks` is a non-empty array and stops there; the body itself is
|
||||
validated by the block registry, which runs in the editor and in the renderer. So
|
||||
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
|
||||
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
|
||||
Templates once, and you have checked the half that boot cannot.
|
||||
|
||||
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
|
||||
triggers — core binds the owner from the calling module and never reads it from
|
||||
the arguments, so there is no shape of this call that fires somebody else's
|
||||
event. It returns nothing and, in production, never throws: there is nothing a
|
||||
module could correctly do about a delivery failure from inside a game-event
|
||||
handler, so there is nothing to await. **Outside production it does throw**, at
|
||||
your call site — a payload that does not match the contract you declared is a bug
|
||||
rather than a condition, and the throw is how you meet it in your own tests
|
||||
instead of in an operator's log six weeks later.
|
||||
|
||||
Beside it is the one call that skips the rules entirely.
|
||||
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
|
||||
inbox. Reach for it when there is nothing for an operator to decide — a job that
|
||||
person started has finished — and for anything else use a trigger, so the message
|
||||
can be turned off, re-targeted, or sent by mail as well without a code change. The
|
||||
posture is `emit`'s: the owner is bound from the calling module, it returns
|
||||
nothing, and it will not tell you that the user has that channel switched off,
|
||||
because a module that could see that could enumerate people's preferences one
|
||||
write at a time.
|
||||
|
||||
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
|
||||
thirty seconds and emits only when the world's online state actually changed.
|
||||
Core's cooldown and hourly cap would both hold if it did not — but leaning on
|
||||
them means emitting "the world is still up" and calling it news, and the operator
|
||||
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
|
||||
Two smaller traps sit inside the same function and are worth reading in
|
||||
`template/server/boot.js`: the previous state has to be read *before* the write,
|
||||
or every poll looks like no change at all, and the very first boot has no previous
|
||||
state, which is not a change either.
|
||||
|
||||
### Becoming the source of Teams
|
||||
|
||||
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
|
||||
|
||||
Reference in New Issue
Block a user